@backmatter/studio 0.0.16 → 0.0.18

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 (45) hide show
  1. package/dist/chunks/{UiKitApp-_EDqWID3.js → UiKitApp-DkuQBiQZ.js} +2 -2
  2. package/dist/chunks/{WorkspaceRoutes-B2PrFgpz.js → WorkspaceRoutes-DVvb3N-V.js} +27489 -26622
  3. package/dist/chunks/{dashboard-BnlnMnnr.js → dashboard-B4FITg6c.js} +2 -2
  4. package/dist/chunks/{define-studio-BYtYDGLX.js → define-studio-DlYjgrwr.js} +12 -16
  5. package/dist/chunks/{index-D_BHF_8q.js → index-CegtXnEI.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 +45 -1
  10. package/dist/site-widget.js +2799 -2791
  11. package/dist/studio.css +113 -15
  12. package/package.json +2 -2
  13. package/src/api.ts +22 -0
  14. package/src/chat/ChatProvider.tsx +6 -0
  15. package/src/chat/Composer.tsx +3 -1
  16. package/src/chrome/Toast.tsx +115 -0
  17. package/src/chrome/WorkspaceStatusBar.tsx +12 -3
  18. package/src/chrome/commit-error.ts +13 -0
  19. package/src/chrome/pull-failure.ts +61 -0
  20. package/src/{pages/code-session-routes.ts → content-routes.ts} +6 -6
  21. package/src/fields/FieldControl.tsx +771 -0
  22. package/src/fields/LinkFieldInput.tsx +315 -0
  23. package/src/fields/choose-field-input.ts +69 -0
  24. package/src/fields/link-targets.ts +60 -0
  25. package/src/fields/load-relation-documents.ts +35 -0
  26. package/src/fields/omit-field.ts +12 -0
  27. package/src/fields/relation-targets.ts +44 -0
  28. package/src/fields/resolve-object-fields.ts +21 -0
  29. package/src/host-path.ts +13 -0
  30. package/src/pages/BlockSettingsDialog.tsx +19 -115
  31. package/src/pages/BlocksList.tsx +43 -35
  32. package/src/pages/BodyProse.tsx +16 -1
  33. package/src/pages/CodeSessionPage.tsx +186 -248
  34. package/src/pages/DocEditorPage.tsx +168 -213
  35. package/src/pages/MediaFieldInput.tsx +609 -26
  36. package/src/pages/MediaGalleryPage.tsx +14 -3
  37. package/src/pages/PreviewAddress.tsx +263 -0
  38. package/src/pages/RenderedBody.tsx +17 -2
  39. package/src/pages/branch-notice.ts +16 -0
  40. package/src/pages/code-session-boot.ts +0 -12
  41. package/src/pages/code-session-context.tsx +2 -0
  42. package/src/pages/media-selection.ts +79 -0
  43. package/src/shell-resets.css +15 -0
  44. package/src/ui/Input.tsx +178 -20
  45. package/src/ui/popup.ts +11 -2
@@ -0,0 +1,315 @@
1
+ // packages/studio/src/fields/LinkFieldInput.tsx
2
+ import { useRef, useState } from "react";
3
+ import * as stylex from "@stylexjs/stylex";
4
+ import {
5
+ documentLabel,
6
+ internalSuffixError,
7
+ joinInternalLink,
8
+ linkSide,
9
+ matchesWhere,
10
+ routeFor,
11
+ splitInternalLink,
12
+ } from "@backmatter/core";
13
+ import type { SchemaField } from "../api";
14
+ import { colors, radius, space, type } from "../styles/tokens.stylex";
15
+ import { Input, Select } from "../ui/Input";
16
+
17
+ type ModelRef = {
18
+ type: string;
19
+ external?: boolean;
20
+ route?: string | false;
21
+ indexSlug?: string;
22
+ fields?: Record<string, SchemaField>;
23
+ };
24
+ type RelationDocument = { slug: string; frontmatter: Record<string, unknown> };
25
+ type RelationDocumentLists = Record<string, RelationDocument[]>;
26
+
27
+ function routableTypes(field: SchemaField, models: ModelRef[]): ModelRef[] {
28
+ if (field.models && field.models.length > 0) {
29
+ return field.models
30
+ .map((type) => models.find((model) => model.type === type))
31
+ .filter((model): model is ModelRef => Boolean(model && !model.external && model.route !== false));
32
+ }
33
+ return models.filter((model) => !model.external && model.route !== false);
34
+ }
35
+
36
+ function suffixPresent(value: string): { query: boolean; anchor: boolean } {
37
+ const hashIndex = value.indexOf("#");
38
+ const preHash = hashIndex === -1 ? value : value.slice(0, hashIndex);
39
+ return { query: preHash.includes("?"), anchor: hashIndex !== -1 };
40
+ }
41
+
42
+ function internalSuffixState(value: string): {
43
+ parts: ReturnType<typeof splitInternalLink>;
44
+ showSuffix: boolean;
45
+ selectValue: string;
46
+ } {
47
+ const parts = splitInternalLink(value);
48
+ const side = linkSide(value);
49
+ if (side !== "internal") {
50
+ return { parts, showSuffix: false, selectValue: value };
51
+ }
52
+ const present = suffixPresent(value);
53
+ const suffixErr = internalSuffixError(
54
+ { query: parts.query, anchor: parts.anchor },
55
+ present,
56
+ );
57
+ if (suffixErr !== null) {
58
+ return { parts, showSuffix: false, selectValue: value };
59
+ }
60
+ return { parts, showSuffix: true, selectValue: parts.path };
61
+ }
62
+
63
+ function buildRouteOptions(
64
+ field: SchemaField,
65
+ models: ModelRef[],
66
+ documentLists: RelationDocumentLists | undefined,
67
+ ): Array<{ value: string; label: string }> {
68
+ if (!documentLists) return [];
69
+ const options: Array<{ value: string; label: string }> = [];
70
+ for (const model of routableTypes(field, models)) {
71
+ const docs = documentLists[model.type] ?? [];
72
+ for (const doc of docs) {
73
+ if (!matchesWhere({ slug: doc.slug, frontmatter: doc.frontmatter }, field.where)) {
74
+ continue;
75
+ }
76
+ const route = routeFor(model, doc.slug);
77
+ if (route === "") continue;
78
+ const label = documentLabel(doc.frontmatter, model);
79
+ options.push({
80
+ value: route,
81
+ label: label ? `${label} ${route}` : route,
82
+ });
83
+ }
84
+ }
85
+ options.sort((a, b) => a.label.localeCompare(b.label));
86
+ return options;
87
+ }
88
+
89
+ function SideToggle({
90
+ internalPressed,
91
+ readOnly,
92
+ onPick,
93
+ }: {
94
+ internalPressed: boolean;
95
+ readOnly?: boolean;
96
+ onPick: (side: "internal" | "external") => void;
97
+ }) {
98
+ return (
99
+ <div {...stylex.props(styles.sideRow)}>
100
+ <button
101
+ type="button"
102
+ aria-pressed={internalPressed}
103
+ disabled={readOnly}
104
+ {...stylex.props(styles.sideButton, internalPressed && styles.sideButtonPressed)}
105
+ onClick={() => {
106
+ if (readOnly || internalPressed) return;
107
+ onPick("internal");
108
+ }}
109
+ >
110
+ Internal
111
+ </button>
112
+ <button
113
+ type="button"
114
+ aria-pressed={!internalPressed}
115
+ disabled={readOnly}
116
+ {...stylex.props(styles.sideButton, !internalPressed && styles.sideButtonPressed)}
117
+ onClick={() => {
118
+ if (readOnly || !internalPressed) return;
119
+ onPick("external");
120
+ }}
121
+ >
122
+ External
123
+ </button>
124
+ </div>
125
+ );
126
+ }
127
+
128
+ export function LinkFieldInput({
129
+ name,
130
+ field,
131
+ value,
132
+ models,
133
+ documentLists,
134
+ readOnly,
135
+ onChange,
136
+ }: {
137
+ name: string;
138
+ field: SchemaField;
139
+ value: unknown;
140
+ models: ModelRef[];
141
+ documentLists?: RelationDocumentLists;
142
+ readOnly?: boolean;
143
+ onChange: (value: unknown) => void;
144
+ }) {
145
+ const raw = value == null || value === "" ? "" : String(value);
146
+ const sideToggle = field.to === undefined;
147
+ const lastEmittedRawRef = useRef<string | null>(null);
148
+ const emitChange = (next: unknown) => {
149
+ lastEmittedRawRef.current = next == null || next === "" ? "" : String(next);
150
+ onChange(next);
151
+ };
152
+ const [editorSide, setEditorSide] = useState<"internal" | "external">(() =>
153
+ linkSide(raw) === "external" ? "external" : "internal",
154
+ );
155
+ const [pendingClearOf, setPendingClearOf] = useState<string | null>(null);
156
+ const [lastSyncedRaw, setLastSyncedRaw] = useState(raw);
157
+ if (raw !== lastSyncedRaw) {
158
+ setLastSyncedRaw(raw);
159
+ if (raw !== lastEmittedRawRef.current) {
160
+ lastEmittedRawRef.current = null;
161
+ if (raw !== "") {
162
+ setEditorSide(linkSide(raw) === "external" ? "external" : "internal");
163
+ }
164
+ }
165
+ if (pendingClearOf !== null && raw !== pendingClearOf) {
166
+ setPendingClearOf(null);
167
+ }
168
+ }
169
+
170
+ if (field.to === "external") {
171
+ return (
172
+ <Input
173
+ value={raw}
174
+ readOnly={readOnly}
175
+ onChange={(event) => {
176
+ if (readOnly) return;
177
+ const next = event.target.value;
178
+ emitChange(next === "" ? undefined : next);
179
+ }}
180
+ />
181
+ );
182
+ }
183
+
184
+ const forceInternal = field.to === "internal";
185
+ const internalUi = forceInternal || editorSide !== "external";
186
+ const staleInternalOnExternalSide =
187
+ editorSide === "external" && raw !== "" && raw === pendingClearOf;
188
+ const externalInputValue = staleInternalOnExternalSide ? "" : raw;
189
+
190
+ if (!internalUi) {
191
+ return (
192
+ <div {...stylex.props(styles.stack)}>
193
+ {sideToggle ? (
194
+ <SideToggle
195
+ internalPressed={false}
196
+ readOnly={readOnly}
197
+ onPick={(picked) => {
198
+ if (readOnly) return;
199
+ setEditorSide(picked);
200
+ if (raw !== "") {
201
+ setPendingClearOf(raw);
202
+ emitChange(undefined);
203
+ }
204
+ }}
205
+ />
206
+ ) : null}
207
+ <Input
208
+ value={externalInputValue}
209
+ readOnly={readOnly}
210
+ onChange={(event) => {
211
+ if (readOnly) return;
212
+ const next = event.target.value;
213
+ emitChange(next === "" ? undefined : next);
214
+ }}
215
+ />
216
+ </div>
217
+ );
218
+ }
219
+
220
+ const { parts, showSuffix, selectValue } = raw === "" ? {
221
+ parts: { path: "", query: "", anchor: "" },
222
+ showSuffix: false,
223
+ selectValue: "",
224
+ } : internalSuffixState(raw);
225
+
226
+ const options = buildRouteOptions(field, models, documentLists);
227
+ if (selectValue && !options.some((option) => option.value === selectValue)) {
228
+ options.unshift({ value: selectValue, label: selectValue });
229
+ }
230
+
231
+ const internalPressed = editorSide !== "external";
232
+
233
+ return (
234
+ <div {...stylex.props(styles.stack)}>
235
+ {sideToggle ? (
236
+ <SideToggle
237
+ internalPressed={internalPressed}
238
+ readOnly={readOnly}
239
+ onPick={(picked) => {
240
+ if (readOnly) return;
241
+ setEditorSide(picked);
242
+ if (raw !== "") {
243
+ setPendingClearOf(raw);
244
+ emitChange(undefined);
245
+ }
246
+ }}
247
+ />
248
+ ) : null}
249
+ <Select
250
+ name={name}
251
+ chip
252
+ searchable
253
+ clearable={!field.required}
254
+ placeholder="Select a value"
255
+ options={options}
256
+ value={selectValue || null}
257
+ disabled={readOnly}
258
+ onValueChange={(next) => {
259
+ if (readOnly) return;
260
+ if (next == null) {
261
+ emitChange(undefined);
262
+ return;
263
+ }
264
+ emitChange(joinInternalLink(next, showSuffix ? parts.query : "", showSuffix ? parts.anchor : ""));
265
+ }}
266
+ />
267
+ <Input
268
+ aria-label="Query"
269
+ value={showSuffix ? parts.query : ""}
270
+ readOnly={readOnly || !showSuffix}
271
+ onChange={(event) => {
272
+ if (readOnly || !showSuffix) return;
273
+ emitChange(joinInternalLink(parts.path, event.target.value, parts.anchor));
274
+ }}
275
+ />
276
+ <Input
277
+ aria-label="Anchor"
278
+ value={showSuffix ? parts.anchor : ""}
279
+ readOnly={readOnly || !showSuffix}
280
+ onChange={(event) => {
281
+ if (readOnly || !showSuffix) return;
282
+ emitChange(joinInternalLink(parts.path, parts.query, event.target.value));
283
+ }}
284
+ />
285
+ </div>
286
+ );
287
+ }
288
+
289
+ const styles = stylex.create({
290
+ stack: {
291
+ display: "flex",
292
+ flexDirection: "column",
293
+ gap: space.sm,
294
+ },
295
+ sideRow: {
296
+ display: "flex",
297
+ gap: space.xs,
298
+ },
299
+ sideButton: {
300
+ paddingInline: space.md,
301
+ paddingBlock: space.xs,
302
+ borderRadius: radius.md,
303
+ borderWidth: 1,
304
+ borderStyle: "solid",
305
+ borderColor: colors.border,
306
+ backgroundColor: colors.bgInput,
307
+ color: colors.text,
308
+ fontSize: type.sm,
309
+ cursor: "pointer",
310
+ },
311
+ sideButtonPressed: {
312
+ borderColor: colors.text,
313
+ fontWeight: 600,
314
+ },
315
+ });
@@ -0,0 +1,69 @@
1
+ // packages/studio/src/fields/choose-field-input.ts
2
+ import type { SchemaField } from "../api";
3
+ import { resolveFieldInput, type StudioDefinition } from "../define-studio";
4
+
5
+ export type FieldInputKind =
6
+ | "custom"
7
+ | "select"
8
+ | "color"
9
+ | "slider"
10
+ | "relation"
11
+ | "object"
12
+ | "array"
13
+ | "media"
14
+ | "boolean"
15
+ | "number"
16
+ | "blocks"
17
+ | "link"
18
+ | "text";
19
+
20
+ export function chooseFieldInput(
21
+ definition: StudioDefinition,
22
+ field: SchemaField,
23
+ models: Array<{ type: string; external?: boolean }>,
24
+ ): FieldInputKind {
25
+ if (resolveFieldInput(definition, field)) {
26
+ return "custom";
27
+ }
28
+
29
+ if (field.kind === "text") {
30
+ if (field.options && field.options.length > 0) {
31
+ return "select";
32
+ }
33
+ if (field.input === "color") {
34
+ return "color";
35
+ }
36
+ }
37
+
38
+ if (field.kind === "number") {
39
+ if (typeof field.min === "number" && typeof field.max === "number") {
40
+ return "slider";
41
+ }
42
+ }
43
+
44
+ if (field.kind === "relation") {
45
+ const target = models.find((m) => m.type === field.relationTo);
46
+ if (target && !target.external) {
47
+ return "relation";
48
+ }
49
+ return "text";
50
+ }
51
+
52
+ if (field.kind === "link") {
53
+ return "link";
54
+ }
55
+
56
+ if (field.kind === "object") {
57
+ return "object";
58
+ }
59
+ if (field.kind === "array") {
60
+ return "array";
61
+ }
62
+
63
+ if (field.kind === "media") return "media";
64
+ if (field.kind === "boolean") return "boolean";
65
+ if (field.kind === "number") return "number";
66
+ if (field.kind === "blocks") return "blocks";
67
+
68
+ return "text";
69
+ }
@@ -0,0 +1,60 @@
1
+ // packages/studio/src/fields/link-targets.ts
2
+ import type { SchemaField } from "../api";
3
+ import { resolveObjectFields } from "./resolve-object-fields";
4
+
5
+ type ModelRef = { type: string; external?: boolean; route?: string | false };
6
+ type ObjectDef = { name: string; fields: Record<string, SchemaField> };
7
+
8
+ function modelTypeLoadable(type: string, models: ModelRef[]): boolean {
9
+ const model = models.find((item) => item.type === type);
10
+ return Boolean(model && !model.external && model.route !== false);
11
+ }
12
+
13
+ function collectFromFields(
14
+ fields: Record<string, SchemaField>,
15
+ models: ModelRef[],
16
+ objects: ObjectDef[],
17
+ visiting: Set<string>,
18
+ targets: Set<string>,
19
+ ): void {
20
+ for (const field of Object.values(fields)) {
21
+ if (field.kind === "link") {
22
+ if (field.to === "external") {
23
+ // external-only links do not load documents
24
+ } else if (field.models && field.models.length > 0) {
25
+ for (const type of field.models) {
26
+ if (modelTypeLoadable(type, models)) {
27
+ targets.add(type);
28
+ }
29
+ }
30
+ } else {
31
+ for (const model of models) {
32
+ if (!model.external && model.route !== false) {
33
+ targets.add(model.type);
34
+ }
35
+ }
36
+ }
37
+ }
38
+
39
+ const nested = resolveObjectFields(field, objects);
40
+ if (!nested) continue;
41
+
42
+ let nextVisiting = visiting;
43
+ if (field.object && (field.kind === "object" || field.kind === "array")) {
44
+ if (visiting.has(field.object)) continue;
45
+ nextVisiting = new Set(visiting).add(field.object);
46
+ }
47
+
48
+ collectFromFields(nested, models, objects, nextVisiting, targets);
49
+ }
50
+ }
51
+
52
+ export function linkDocumentTypes(
53
+ fields: Record<string, SchemaField>,
54
+ models: ModelRef[],
55
+ objects: ObjectDef[],
56
+ ): string[] {
57
+ const targets = new Set<string>();
58
+ collectFromFields(fields, models, objects, new Set<string>(), targets);
59
+ return [...targets].sort();
60
+ }
@@ -0,0 +1,35 @@
1
+ // packages/studio/src/fields/load-relation-documents.ts
2
+ type DocListRow = { type: string; slug: string; status: string };
3
+ type DocEntry = { slug: string; frontmatter: Record<string, unknown> };
4
+
5
+ export async function loadRelationDocuments(
6
+ types: string[],
7
+ list: () => Promise<DocListRow[]>,
8
+ read: (type: string, slug: string) => Promise<{ frontmatter: Record<string, unknown> }>,
9
+ ): Promise<Record<string, DocEntry[]>> {
10
+ const wanted = new Set(types);
11
+ const grouped: Record<string, DocEntry[]> = Object.fromEntries(types.map((type) => [type, []]));
12
+ let rows: DocListRow[];
13
+ try {
14
+ rows = await list();
15
+ } catch {
16
+ return grouped;
17
+ }
18
+
19
+ const selected = rows.filter((row) => wanted.has(row.type) && row.status !== "deleted");
20
+ const loaded = await Promise.all(
21
+ selected.map(async (row) => {
22
+ try {
23
+ const { frontmatter } = await read(row.type, row.slug);
24
+ return { type: row.type, entry: { slug: row.slug, frontmatter } satisfies DocEntry };
25
+ } catch {
26
+ return null;
27
+ }
28
+ }),
29
+ );
30
+ for (const item of loaded) {
31
+ if (!item) continue;
32
+ grouped[item.type]!.push(item.entry);
33
+ }
34
+ return grouped;
35
+ }
@@ -0,0 +1,12 @@
1
+ // packages/studio/src/fields/omit-field.ts
2
+ export function omitField(
3
+ record: Record<string, unknown>,
4
+ name: string,
5
+ next: unknown,
6
+ ): Record<string, unknown> {
7
+ if (next === undefined) {
8
+ const { [name]: _removed, ...rest } = record;
9
+ return rest;
10
+ }
11
+ return { ...record, [name]: next };
12
+ }
@@ -0,0 +1,44 @@
1
+ // packages/studio/src/fields/relation-targets.ts
2
+ import type { SchemaField } from "../api";
3
+ import { resolveObjectFields } from "./resolve-object-fields";
4
+
5
+ type ModelRef = { type: string; external?: boolean };
6
+ type ObjectDef = { name: string; fields: Record<string, SchemaField> };
7
+
8
+ function collectFromFields(
9
+ fields: Record<string, SchemaField>,
10
+ models: ModelRef[],
11
+ objects: ObjectDef[],
12
+ visiting: Set<string>,
13
+ targets: Set<string>,
14
+ ): void {
15
+ for (const field of Object.values(fields)) {
16
+ if (field.kind === "relation" && field.relationTo) {
17
+ const target = models.find((model) => model.type === field.relationTo);
18
+ if (target && !target.external) {
19
+ targets.add(field.relationTo);
20
+ }
21
+ }
22
+
23
+ const nested = resolveObjectFields(field, objects);
24
+ if (!nested) continue;
25
+
26
+ let nextVisiting = visiting;
27
+ if (field.object && (field.kind === "object" || field.kind === "array")) {
28
+ if (visiting.has(field.object)) continue;
29
+ nextVisiting = new Set(visiting).add(field.object);
30
+ }
31
+
32
+ collectFromFields(nested, models, objects, nextVisiting, targets);
33
+ }
34
+ }
35
+
36
+ export function relationTargets(
37
+ fields: Record<string, SchemaField>,
38
+ models: ModelRef[],
39
+ objects: ObjectDef[],
40
+ ): string[] {
41
+ const targets = new Set<string>();
42
+ collectFromFields(fields, models, objects, new Set<string>(), targets);
43
+ return [...targets].sort();
44
+ }
@@ -0,0 +1,21 @@
1
+ // packages/studio/src/fields/resolve-object-fields.ts
2
+ import type { SchemaField } from "../api";
3
+
4
+ export function resolveObjectFields(
5
+ field: SchemaField,
6
+ objects: Array<{ name: string; fields: Record<string, SchemaField> }>,
7
+ ): Record<string, SchemaField> | null {
8
+ if (field.fields) {
9
+ return field.fields;
10
+ }
11
+ if (field.object) {
12
+ const entry = objects.find((object) => object.name === field.object);
13
+ return entry?.fields ?? null;
14
+ }
15
+ return null;
16
+ }
17
+
18
+ export function nestedStartsCollapsed(ancestorObjectNames: string[], objectName: string | undefined): boolean {
19
+ if (!objectName) return false;
20
+ return ancestorObjectNames.includes(objectName);
21
+ }
@@ -0,0 +1,13 @@
1
+ // packages/studio/src/host-path.ts
2
+
3
+ /** A typed or pasted address, as a path on the host. */
4
+ export function hostPath(input: string): string {
5
+ const trimmed = input.trim();
6
+ if (!trimmed) return "/";
7
+ try {
8
+ const url = new URL(trimmed);
9
+ return `${url.pathname}${url.search}${url.hash}` || "/";
10
+ } catch {
11
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
12
+ }
13
+ }