@backmatter/studio 0.0.17 → 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.
@@ -3,7 +3,7 @@ var Xe = (e, t, n) => t in e ? Ke(e, t, { enumerable: !0, configurable: !0, writ
3
3
  var D = (e, t, n) => Xe(e, typeof t != "symbol" ? t + "" : t, n);
4
4
  import { jsxs as k, jsx as o, Fragment as ge } from "react/jsx-runtime";
5
5
  import { useRef as Ye, useCallback as Qe, useSyncExternalStore as Ze, useState as w, useEffect as V, lazy as et, Suspense as tt } from "react";
6
- import { U as nt, aj as ve, ak as rt, P as _, al as st, p as it, am as at, an as Ne, ao as Ce, ap as Ie, aq as ot, ar as lt, N as qe, as as ct, at as ie, ad as J, W as ze, au as ut, av as Fe, ae as dt, ag as re, a7 as Q, a8 as Z, V as ae, aw as ft, Q as z, ax as ht, G as be, ay as ee, af as pt, az as gt, aA as mt, aB as xt, f as yt, aC as vt, a3 as bt, aD as wt, aE as kt, w as St, B as Et, F as Rt, aF as _t, aG as Ot, aH as Tt, H as Pt, aI as jt, R as Lt, I as At, J as Ut, K as Nt, L as F, M as Ct, O as It } from "./WorkspaceRoutes-DH1LD-2Q.js";
6
+ import { U as nt, aj as ve, ak as rt, P as _, al as st, p as it, am as at, an as Ne, ao as Ce, ap as Ie, aq as ot, ar as lt, N as qe, as as ct, at as ie, ad as J, W as ze, au as ut, av as Fe, ae as dt, ag as re, a7 as Q, a8 as Z, V as ae, aw as ft, Q as z, ax as ht, G as be, ay as ee, af as pt, az as gt, aA as mt, aB as xt, f as yt, aC as vt, a3 as bt, aD as wt, aE as kt, w as St, B as Et, F as Rt, aF as _t, aG as Ot, aH as Tt, H as Pt, aI as jt, R as Lt, I as At, J as Ut, K as Nt, L as F, M as Ct, O as It } from "./WorkspaceRoutes-DVvb3N-V.js";
7
7
  const te = /* @__PURE__ */ Object.create(null), W = (e) => {
8
8
  var t, n;
9
9
  return ((t = globalThis.process) == null ? void 0 : t.env) || ((n = globalThis.Deno) == null ? void 0 : n.env.toObject()) || globalThis.__env__ || (e ? te : globalThis);
@@ -1702,7 +1702,7 @@ function kr(e) {
1702
1702
  const t = e.replace(/\/$/, "") || "/";
1703
1703
  return t === "/__ui" || /^\/__ui\/[^/]+$/.test(t);
1704
1704
  }
1705
- const Sr = et(() => import("./UiKitApp-D7vllRQE.js").then((e) => ({
1705
+ const Sr = et(() => import("./UiKitApp-DkuQBiQZ.js").then((e) => ({
1706
1706
  default: e.UiKitApp
1707
1707
  })));
1708
1708
  function Pr() {
@@ -1,7 +1,7 @@
1
1
  var Rs = Object.defineProperty;
2
2
  var Es = (r, e, t) => e in r ? Rs(r, e, { enumerable: !0, configurable: !0, writable: !0, value: t }) : r[e] = t;
3
3
  var M = (r, e, t) => Es(r, typeof e != "symbol" ? e + "" : e, t);
4
- import { aJ as Bt, aK as Bs, aL as a0, aM as ct, aN as xt, aO as ea, aP as Gn, aQ as Is, aR as Ls, aS as Ds, aT as La, aU as Ps, aV as Os, aW as Le, aX as qs } from "./WorkspaceRoutes-DH1LD-2Q.js";
4
+ import { aJ as Bt, aK as Bs, aL as a0, aM as ct, aN as xt, aO as ea, aP as Gn, aQ as Is, aR as Ls, aS as Ds, aT as La, aU as Ps, aV as Os, aW as Le, aX as qs } from "./WorkspaceRoutes-DVvb3N-V.js";
5
5
  function Fs() {
6
6
  return {
7
7
  enter: {
package/dist/dashboard.js CHANGED
@@ -1,5 +1,5 @@
1
- import { A as a, i as s } from "./chunks/dashboard-Dsaeud2x.js";
2
- import { z as t, A as f } from "./chunks/WorkspaceRoutes-DH1LD-2Q.js";
1
+ import { A as a, i as s } from "./chunks/dashboard-B4FITg6c.js";
2
+ import { z as t, A as f } from "./chunks/WorkspaceRoutes-DVvb3N-V.js";
3
3
  export {
4
4
  a as DashboardApp,
5
5
  t as configureStudio,
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { jsx as t, jsxs as u } from "react/jsx-runtime";
2
2
  import { useLayoutEffect as ae, useEffect as f, useRef as Q, useState as h, useMemo as se } from "react";
3
- import { i as X, T as ce, E as le, C as ue, a as de, b as xe, u as me, D as _, c as Y, d as I, e as fe, f as Z, g as G, h as pe, p as E, j as C, k as he, l as ge, m as S, r as we, n as be, s as ye, o as ve, q as ke, t as qe, v as ze, w as Ee, B as Ce, x as Ne, S as Se, y as Re, z as ee, A as U, F as Pe, G as Le, H as Ue, R as je, I as Te, J as Me, K as $e, L as R, N as K, M as De, O as Fe, P as H, Q as Oe } from "./chunks/WorkspaceRoutes-DH1LD-2Q.js";
4
- import { U as lt } from "./chunks/WorkspaceRoutes-DH1LD-2Q.js";
3
+ import { i as X, T as ce, E as le, C as ue, a as de, b as xe, u as me, D as _, c as Y, d as I, e as fe, f as Z, g as G, h as pe, p as E, j as C, k as he, l as ge, m as S, r as we, n as be, s as ye, o as ve, q as ke, t as qe, v as ze, w as Ee, B as Ce, x as Ne, S as Se, y as Re, z as ee, A as U, F as Pe, G as Le, H as Ue, R as je, I as Te, J as Me, K as $e, L as R, N as K, M as De, O as Fe, P as H, Q as Oe } from "./chunks/WorkspaceRoutes-DVvb3N-V.js";
4
+ import { U as lt } from "./chunks/WorkspaceRoutes-DVvb3N-V.js";
5
5
  import { u as Ae, S as Be } from "./chunks/define-studio-DlYjgrwr.js";
6
6
  import { d as dt } from "./chunks/define-studio-DlYjgrwr.js";
7
7
  const P = {
package/dist/studio.css CHANGED
@@ -537,10 +537,6 @@
537
537
  animation-duration: .2s;
538
538
  }
539
539
 
540
- .xof6966 {
541
- animation-duration: .7s;
542
- }
543
-
544
540
  .xemfg65 {
545
541
  animation-duration: .8s;
546
542
  }
@@ -561,10 +557,6 @@
561
557
  animation-name: x18re5ia-B;
562
558
  }
563
559
 
564
- .xpedhrg {
565
- animation-name: x1fy8ia8-B;
566
- }
567
-
568
560
  .x1bzz7ut {
569
561
  animation-name: x1tbvpe3-B;
570
562
  }
@@ -1125,6 +1117,10 @@
1125
1117
  grid-template-columns: repeat(2, minmax(0, 1fr));
1126
1118
  }
1127
1119
 
1120
+ .xbqmlv7 {
1121
+ grid-template-columns: repeat(4, 48px);
1122
+ }
1123
+
1128
1124
  .x1sipb5g {
1129
1125
  grid-template-columns: repeat(4, minmax(0, 1fr));
1130
1126
  }
@@ -1974,6 +1970,10 @@
1974
1970
  bottom: var(--xvc7yhn);
1975
1971
  }
1976
1972
 
1973
+ .xmll18r {
1974
+ height: 1.25rem;
1975
+ }
1976
+
1977
1977
  .x5yr21d {
1978
1978
  height: 100%;
1979
1979
  }
@@ -2638,6 +2638,10 @@
2638
2638
  top: var(--x1y1rrep);
2639
2639
  }
2640
2640
 
2641
+ .xwqq7k2 {
2642
+ width: 1.25rem;
2643
+ }
2644
+
2641
2645
  .xh8yej3 {
2642
2646
  width: 100%;
2643
2647
  }
@@ -2738,6 +2742,10 @@
2738
2742
  width: 4.5rem;
2739
2743
  }
2740
2744
 
2745
+ .x100vrsf {
2746
+ width: 40px;
2747
+ }
2748
+
2741
2749
  .x187nhsf {
2742
2750
  width: 44px;
2743
2751
  }
@@ -2778,6 +2786,10 @@
2778
2786
  width: calc(min(360px, 38vw) - 48px);
2779
2787
  }
2780
2788
 
2789
+ .xeq5yr9 {
2790
+ width: fit-content;
2791
+ }
2792
+
2781
2793
  .x3bfboh {
2782
2794
  width: min(360px, 38vw);
2783
2795
  }
@@ -3022,12 +3034,6 @@
3022
3034
  }
3023
3035
  }
3024
3036
 
3025
- @keyframes x1fy8ia8-B {
3026
- to {
3027
- transform: rotate(360deg);
3028
- }
3029
- }
3030
-
3031
3037
  @keyframes x1wc8ddo-B {
3032
3038
  from {
3033
3039
  transform: rotate(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backmatter/studio",
3
- "version": "0.0.17",
3
+ "version": "0.0.18",
4
4
  "description": "The Backmatter editing surface: document editor, AI chat, publish. Mounted on your site or hosted on the dashboard.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -56,7 +56,7 @@
56
56
  },
57
57
  "dependencies": {
58
58
  "@ai-sdk/react": "^4.0.100",
59
- "@backmatter/core": "^0.0.34",
59
+ "@backmatter/core": "^0.0.35",
60
60
  "@base-ui/react": "^1.6.0",
61
61
  "@central-icons-react/round-outlined-radius-2-stroke-1.5": "1.2.15",
62
62
  "@dnd-kit/abstract": "^0.5.0",
package/src/api.ts CHANGED
@@ -350,8 +350,11 @@ export function unbindGithub(workspaceId: string) {
350
350
 
351
351
  export type SchemaField = {
352
352
  kind: string;
353
+ multiple?: boolean;
353
354
  required?: boolean;
354
355
  relationTo?: string;
356
+ to?: "internal" | "external";
357
+ models?: string[];
355
358
  object?: string;
356
359
  fields?: Record<string, SchemaField>;
357
360
  /** Studio input name from `field.text({ input })`; see `defineStudio`. */
@@ -1,4 +1,4 @@
1
- // packages/studio/src/pages/code-session-routes.ts
1
+ // packages/studio/src/content-routes.ts
2
2
  import {
3
3
  isRoutable,
4
4
  isTemplateSlug,
@@ -7,15 +7,15 @@ import {
7
7
  type RouteModel,
8
8
  } from "@backmatter/core";
9
9
 
10
- export type ContentRouteModel = RouteModel & {
10
+ type ContentFileModel = RouteModel & {
11
11
  path: string;
12
12
  external?: boolean;
13
13
  template?: string;
14
14
  };
15
15
 
16
- /** Public routes for the content files in a code session checkout. */
17
- export function contentRoutes(
18
- models: readonly ContentRouteModel[],
16
+ /** Public routes for content files. A file that is not a routable document is skipped. */
17
+ export function routesForContentFiles(
18
+ models: readonly ContentFileModel[],
19
19
  files: readonly string[],
20
20
  ): string[] {
21
21
  const routes = new Set<string>();
@@ -35,7 +35,7 @@ export function contentRoutes(
35
35
  }
36
36
 
37
37
  /** Routes whose path contains the address query. A blank query keeps the full list. */
38
- export function matchingRoutes(routes: readonly string[], query: string): string[] {
38
+ export function routesMatching(routes: readonly string[], query: string): string[] {
39
39
  const needle = query.trim().toLowerCase();
40
40
  if (!needle) return [...routes];
41
41
  return routes.filter((route) => route.toLowerCase().includes(needle));
@@ -8,6 +8,7 @@ import { colors, radius, space, type } from "../styles/tokens.stylex";
8
8
  import { Input, Select, Textarea } from "../ui/Input";
9
9
  import { MediaFieldInput } from "../pages/MediaFieldInput";
10
10
  import { chooseFieldInput } from "./choose-field-input";
11
+ import { LinkFieldInput } from "./LinkFieldInput";
11
12
  import { nestedStartsCollapsed, resolveObjectFields } from "./resolve-object-fields";
12
13
 
13
14
  function clamp(n: number, min: number, max: number): number {
@@ -88,7 +89,13 @@ export function JsonField({
88
89
  );
89
90
  }
90
91
 
91
- type ModelRef = { type: string; external?: boolean; fields?: Record<string, SchemaField> };
92
+ type ModelRef = {
93
+ type: string;
94
+ external?: boolean;
95
+ route?: string | false;
96
+ indexSlug?: string;
97
+ fields?: Record<string, SchemaField>;
98
+ };
92
99
  type RelationDocument = { slug: string; frontmatter: Record<string, unknown> };
93
100
  type RelationDocumentLists = Record<string, RelationDocument[]>;
94
101
 
@@ -542,7 +549,9 @@ export function FieldControl({
542
549
  }
543
550
 
544
551
  if (kind === "media") {
545
- return <MediaFieldInput name={name} value={value} readOnly={readOnly} onChange={onChange} />;
552
+ return (
553
+ <MediaFieldInput name={name} field={field} value={value} readOnly={readOnly} onChange={onChange} />
554
+ );
546
555
  }
547
556
 
548
557
  if (kind === "relation") {
@@ -559,6 +568,20 @@ export function FieldControl({
559
568
  );
560
569
  }
561
570
 
571
+ if (kind === "link") {
572
+ return (
573
+ <LinkFieldInput
574
+ name={name}
575
+ field={field}
576
+ value={value}
577
+ models={models}
578
+ documentLists={documentLists}
579
+ readOnly={readOnly}
580
+ onChange={onChange}
581
+ />
582
+ );
583
+ }
584
+
562
585
  if (kind === "boolean") {
563
586
  return (
564
587
  <input
@@ -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
+ });
@@ -14,6 +14,7 @@ export type FieldInputKind =
14
14
  | "boolean"
15
15
  | "number"
16
16
  | "blocks"
17
+ | "link"
17
18
  | "text";
18
19
 
19
20
  export function chooseFieldInput(
@@ -48,6 +49,10 @@ export function chooseFieldInput(
48
49
  return "text";
49
50
  }
50
51
 
52
+ if (field.kind === "link") {
53
+ return "link";
54
+ }
55
+
51
56
  if (field.kind === "object") {
52
57
  return "object";
53
58
  }
@@ -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,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
+ }