@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
@@ -0,0 +1,313 @@
1
+ // packages/studio/src/pages/WorkspaceHomePage.tsx
2
+ import { Link, useOutletContext } from "react-router";
3
+ import * as stylex from "@stylexjs/stylex";
4
+ import { useReplicaState } from "../replica/useReplica";
5
+ import { colors, radius, space, type } from "../styles/tokens.stylex";
6
+ import { PageHeader } from "../ui/Page";
7
+ import { Text } from "../ui/Text";
8
+ import { codeSessionEntry } from "../code-session-entry";
9
+ import type { WorkspaceOutletContext } from "../workspace/types";
10
+ import { homeSummary, commitAge, type HomeRow, type HomeSummary } from "./home-rows";
11
+ import type { RecentCommit } from "../api";
12
+
13
+ const hover = "@media (hover: hover)";
14
+
15
+ export function UncommittedHome({
16
+ summary,
17
+ codeHref = null,
18
+ }: {
19
+ summary: HomeSummary;
20
+ codeHref?: string | null;
21
+ }) {
22
+ const branchDetail =
23
+ summary.branch === summary.defaultBranch
24
+ ? "Default branch"
25
+ : `Default is ${summary.defaultBranch}`;
26
+ return (
27
+ <>
28
+ <PageHeader title={summary.name} description={`On ${summary.branch}`} />
29
+ <div {...stylex.props(styles.grid)}>
30
+ <UncommittedCard summary={summary} />
31
+ <CommitsCard summary={summary} />
32
+ <FactCard
33
+ kicker="Models"
34
+ value={summary.modelCount === 0 ? "None yet" : String(summary.modelCount)}
35
+ detail={
36
+ summary.modelCount === 0
37
+ ? "Pull from GitHub to load models."
38
+ : "Loaded from the repository."
39
+ }
40
+ href={summary.schemaHref}
41
+ />
42
+ <FactCard
43
+ kicker="Branch"
44
+ value={summary.branch}
45
+ detail={branchDetail}
46
+ href={summary.githubHref}
47
+ />
48
+ <FactCard
49
+ kicker="Repository"
50
+ value={summary.repository ?? "Not connected"}
51
+ detail={summary.repository ? "GitHub" : "Connect a repository."}
52
+ href={summary.githubHref}
53
+ />
54
+ <FactCard
55
+ kicker="Site"
56
+ value={summary.siteUrl ?? "No site URL"}
57
+ detail={summary.siteUrl ? "Public origin" : "Add the site origin."}
58
+ href={summary.generalHref}
59
+ />
60
+ {codeHref ? (
61
+ <FactCard
62
+ kicker="Code"
63
+ value="Session"
64
+ detail="Host on the left, chat on the right."
65
+ href={codeHref}
66
+ />
67
+ ) : null}
68
+ </div>
69
+ </>
70
+ );
71
+ }
72
+
73
+ function UncommittedCard({ summary }: { summary: HomeSummary }) {
74
+ const detail =
75
+ summary.uncommitted === 0
76
+ ? "Your working tree is clean."
77
+ : summary.conflicts === 0
78
+ ? "No conflicts."
79
+ : summary.conflicts === 1
80
+ ? "1 conflict"
81
+ : `${summary.conflicts} conflicts`;
82
+ return (
83
+ <section {...stylex.props(styles.card, styles.span)}>
84
+ <Text size="sm" tone="muted" weight="medium">
85
+ Uncommitted
86
+ </Text>
87
+ <p {...stylex.props(styles.value)}>{summary.uncommitted}</p>
88
+ <Text size="sm" tone={summary.conflicts > 0 ? "danger" : "muted"}>
89
+ {detail}
90
+ </Text>
91
+ {summary.recent.length > 0 ? (
92
+ <>
93
+ <Text size="sm" tone="muted">
94
+ Recently changed
95
+ </Text>
96
+ <ul {...stylex.props(styles.recent)}>
97
+ {summary.recent.map((row) => (
98
+ <li key={row.key}>
99
+ <RecentRow row={row} />
100
+ </li>
101
+ ))}
102
+ </ul>
103
+ </>
104
+ ) : null}
105
+ </section>
106
+ );
107
+ }
108
+
109
+ function CommitsCard({ summary }: { summary: HomeSummary }) {
110
+ if (!summary.commits) return null;
111
+ return (
112
+ <section {...stylex.props(styles.card, styles.span)}>
113
+ <Text size="sm" tone="muted" weight="medium">
114
+ Lately
115
+ </Text>
116
+ {summary.commits.length === 0 ? (
117
+ <Text size="sm" tone="muted">
118
+ No commits on this branch yet.
119
+ </Text>
120
+ ) : (
121
+ <ul {...stylex.props(styles.recent)}>
122
+ {summary.commits.map((commit) => (
123
+ <li key={commit.sha}>
124
+ <CommitRow commit={commit} repository={summary.repository} />
125
+ </li>
126
+ ))}
127
+ </ul>
128
+ )}
129
+ </section>
130
+ );
131
+ }
132
+
133
+ function CommitRow({
134
+ commit,
135
+ repository,
136
+ }: {
137
+ commit: RecentCommit;
138
+ repository: string | null;
139
+ }) {
140
+ const when = commitAge(commit.committedAt, Date.now());
141
+ const who = [commit.author, when, commit.cms ? "CMS" : ""].filter(Boolean).join(" · ");
142
+ const body = (
143
+ <>
144
+ <span {...stylex.props(styles.recentName)}>{commit.message}</span>
145
+ <span {...stylex.props(styles.recentStatus)}>{who}</span>
146
+ </>
147
+ );
148
+ if (!repository) return <div {...stylex.props(styles.recentRow)}>{body}</div>;
149
+ return (
150
+ <a
151
+ href={`https://github.com/${repository}/commit/${commit.sha}`}
152
+ target="_blank"
153
+ rel="noreferrer"
154
+ {...stylex.props(styles.recentRow, styles.recentLink)}
155
+ >
156
+ {body}
157
+ </a>
158
+ );
159
+ }
160
+
161
+ function RecentRow({ row }: { row: HomeRow }) {
162
+ const body = (
163
+ <>
164
+ <span {...stylex.props(styles.recentName, row.route ? styles.recentRoute : null)}>
165
+ {row.route ?? row.label}
166
+ </span>
167
+ <span {...stylex.props(styles.recentStatus)}>
168
+ {row.route ? row.status : `data · ${row.status}`}
169
+ </span>
170
+ </>
171
+ );
172
+ if (!row.href) return <div {...stylex.props(styles.recentRow)}>{body}</div>;
173
+ return (
174
+ <Link to={row.href} {...stylex.props(styles.recentRow, styles.recentLink)}>
175
+ {body}
176
+ </Link>
177
+ );
178
+ }
179
+
180
+ function FactCard({
181
+ kicker,
182
+ value,
183
+ detail,
184
+ href,
185
+ }: {
186
+ kicker: string;
187
+ value: string;
188
+ detail: string;
189
+ href: string;
190
+ }) {
191
+ return (
192
+ <Link to={href} {...stylex.props(styles.card, styles.cardLink)}>
193
+ <Text size="sm" tone="muted" weight="medium">
194
+ {kicker}
195
+ </Text>
196
+ <p {...stylex.props(styles.factValue)}>{value}</p>
197
+ <Text size="sm" tone="muted">
198
+ {detail}
199
+ </Text>
200
+ </Link>
201
+ );
202
+ }
203
+
204
+ export function WorkspaceHomePage() {
205
+ const { workspace, models } = useOutletContext<WorkspaceOutletContext>();
206
+ const git = useReplicaState().git(workspace.id);
207
+ const summary = homeSummary({
208
+ name: workspace.name,
209
+ slug: workspace.slug,
210
+ branch: git?.currentBranch ?? "main",
211
+ defaultBranch: git?.defaultBranch ?? git?.currentBranch ?? "main",
212
+ repository: workspace.githubRepoFullName,
213
+ siteUrl: workspace.siteUrls[0] ?? null,
214
+ modelCount: models.length,
215
+ changes: git?.fetchedChanges ?? git?.changes ?? [],
216
+ models,
217
+ commits: git?.recentCommits ?? null,
218
+ });
219
+ return (
220
+ <UncommittedHome
221
+ summary={summary}
222
+ codeHref={codeSessionEntry(workspace.role, workspace.slug)}
223
+ />
224
+ );
225
+ }
226
+
227
+ const styles = stylex.create({
228
+ grid: {
229
+ display: "grid",
230
+ gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
231
+ gap: space.lg,
232
+ },
233
+ span: {
234
+ gridColumn: "1 / -1",
235
+ },
236
+ card: {
237
+ display: "flex",
238
+ flexDirection: "column",
239
+ gap: space.sm,
240
+ minWidth: 0,
241
+ borderWidth: 1,
242
+ borderStyle: "solid",
243
+ borderColor: colors.border,
244
+ borderRadius: radius.md,
245
+ backgroundColor: colors.bgSurface,
246
+ padding: space.xl,
247
+ textDecoration: "none",
248
+ color: colors.text,
249
+ },
250
+ cardLink: {
251
+ [hover]: {
252
+ ":hover": {
253
+ backgroundColor: colors.bgHover,
254
+ },
255
+ },
256
+ },
257
+ value: {
258
+ margin: 0,
259
+ fontSize: "32px",
260
+ fontWeight: 600,
261
+ letterSpacing: "-0.03em",
262
+ lineHeight: 1,
263
+ },
264
+ factValue: {
265
+ margin: 0,
266
+ overflow: "hidden",
267
+ fontSize: type.lg,
268
+ fontWeight: 600,
269
+ letterSpacing: "-0.02em",
270
+ textOverflow: "ellipsis",
271
+ whiteSpace: "nowrap",
272
+ },
273
+ recent: {
274
+ display: "flex",
275
+ flexDirection: "column",
276
+ gap: space.xs,
277
+ margin: 0,
278
+ marginTop: space.md,
279
+ padding: 0,
280
+ listStyle: "none",
281
+ },
282
+ recentRow: {
283
+ display: "flex",
284
+ alignItems: "center",
285
+ justifyContent: "space-between",
286
+ gap: space.lg,
287
+ minHeight: "28px",
288
+ color: colors.text,
289
+ textDecoration: "none",
290
+ },
291
+ recentLink: {
292
+ [hover]: {
293
+ ":hover": {
294
+ color: colors.textMuted,
295
+ },
296
+ },
297
+ },
298
+ recentName: {
299
+ overflow: "hidden",
300
+ textOverflow: "ellipsis",
301
+ whiteSpace: "nowrap",
302
+ fontSize: type.md,
303
+ },
304
+ recentRoute: {
305
+ fontFamily: type.mono,
306
+ fontSize: type.sm,
307
+ },
308
+ recentStatus: {
309
+ flexShrink: 0,
310
+ fontSize: type.sm,
311
+ color: colors.textMuted,
312
+ },
313
+ });
@@ -12,7 +12,10 @@ import { PageFrame } from "../ui/Page";
12
12
  import { Text } from "../ui/Text";
13
13
  import type { WorkspaceOutletContext } from "../workspace/types";
14
14
  import {
15
+ isWorkspaceContentIndexPath,
15
16
  isWorkspaceEditorPath,
17
+ isWorkspaceHomePath,
18
+ isWorkspaceCodePath,
16
19
  isWorkspaceMediaPath,
17
20
  isWorkspaceSettingsPath,
18
21
  isWorkspaceTypeIndexPath,
@@ -26,9 +29,12 @@ export function WorkspaceLayout() {
26
29
  const { workspaceSlug = "" } = useParams();
27
30
  const location = useLocation();
28
31
  const isEditor = isWorkspaceEditorPath(location.pathname);
32
+ const isContentIndex = isWorkspaceContentIndexPath(location.pathname);
29
33
  const isTypeIndex = isWorkspaceTypeIndexPath(location.pathname);
30
34
  const isSettings = isWorkspaceSettingsPath(location.pathname);
31
35
  const isMedia = isWorkspaceMediaPath(location.pathname);
36
+ const isCode = isWorkspaceCodePath(location.pathname);
37
+ const isHome = isWorkspaceHomePath(location.pathname);
32
38
  const { userId } = useAppChrome();
33
39
  const setNav = useSetWorkspaceNav();
34
40
  const replica = useReplicaState();
@@ -96,7 +102,10 @@ export function WorkspaceLayout() {
96
102
  }
97
103
 
98
104
  return (
99
- <PageFrame flush={isEditor || isTypeIndex || isSettings || isMedia}>
105
+ <PageFrame
106
+ wide={isHome}
107
+ flush={isEditor || isContentIndex || isTypeIndex || isSettings || isMedia || isCode}
108
+ >
100
109
  <Outlet context={outletContext} />
101
110
  </PageFrame>
102
111
  );
@@ -0,0 +1,16 @@
1
+ // packages/studio/src/pages/branch-notice.ts
2
+
3
+ export type BranchNoticeKind = "update" | "edits" | "fold";
4
+
5
+ /** What to show when this session's commit is behind the branch tip. */
6
+ export function branchNoticeKind(input: {
7
+ baseSha: string | null;
8
+ headSha: string | null;
9
+ editCount: number;
10
+ foldPending: boolean;
11
+ }): BranchNoticeKind | null {
12
+ if (!input.baseSha || !input.headSha || input.baseSha === input.headSha) return null;
13
+ if (input.editCount > 0) return "edits";
14
+ if (input.foldPending) return "fold";
15
+ return "update";
16
+ }
@@ -0,0 +1,43 @@
1
+ // packages/studio/src/pages/code-session-boot.ts
2
+ export type HostPhase = "stopped" | "installing" | "starting" | "ready" | "failed";
3
+
4
+ export type BootStepState = "pending" | "active" | "done" | "failed";
5
+
6
+ export type BootStep = { label: string; state: BootStepState };
7
+
8
+ const LABELS = ["Start the container", "Install dependencies", "Start the dev server"] as const;
9
+
10
+ /**
11
+ * The three steps a developer watches before the host shows. `furthest` is the
12
+ * latest phase seen, so a failure lands on the step that was running.
13
+ */
14
+ export function bootSteps(input: {
15
+ opening: boolean;
16
+ phase: HostPhase | null;
17
+ furthest: "installing" | "starting" | null;
18
+ }): BootStep[] {
19
+ const at = (index: number, state: BootStepState): BootStep[] =>
20
+ LABELS.map((label, i) => ({
21
+ label,
22
+ state: i < index ? "done" : i === index ? state : "pending",
23
+ }));
24
+
25
+ if (input.opening || input.phase === null) return at(0, "active");
26
+ if (input.phase === "installing") return at(1, "active");
27
+ if (input.phase === "starting") return at(2, "active");
28
+ if (input.phase === "ready") return LABELS.map((label) => ({ label, state: "done" }));
29
+ if (input.phase === "stopped") return at(0, "failed");
30
+ return at(input.furthest === "starting" ? 2 : input.furthest === "installing" ? 1 : 0, "failed");
31
+ }
32
+
33
+ /** Normalizes what someone types in the address bar to a path inside the host. */
34
+ export function hostPath(input: string): string {
35
+ const trimmed = input.trim();
36
+ if (!trimmed) return "/";
37
+ try {
38
+ const url = new URL(trimmed);
39
+ return `${url.pathname}${url.search}${url.hash}` || "/";
40
+ } catch {
41
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
42
+ }
43
+ }
@@ -0,0 +1,53 @@
1
+ // packages/studio/src/pages/code-session-context.ts
2
+ import {
3
+ createContext,
4
+ useCallback,
5
+ useContext,
6
+ useMemo,
7
+ useState,
8
+ type Dispatch,
9
+ type SetStateAction,
10
+ } from "react";
11
+
12
+ export type CodeSessionContextValue = {
13
+ sessionId: string;
14
+ editCount: number;
15
+ refresh: () => void;
16
+ /** The checkout is being replaced. Chat waits until the host boots. */
17
+ updating?: boolean;
18
+ };
19
+
20
+ const CodeSessionContext = createContext<CodeSessionContextValue | null>(null);
21
+ const CodeSessionPublishContext = createContext<
22
+ Dispatch<SetStateAction<CodeSessionContextValue | null>> | null
23
+ >(null);
24
+
25
+ export function CodeSessionProvider({
26
+ children,
27
+ initial = null,
28
+ }: {
29
+ children: React.ReactNode;
30
+ initial?: CodeSessionContextValue | null;
31
+ }) {
32
+ const [published, setPublished] = useState<CodeSessionContextValue | null>(initial);
33
+ const value = useMemo(() => published, [published]);
34
+ return (
35
+ <CodeSessionContext.Provider value={value}>
36
+ <CodeSessionPublishContext.Provider value={setPublished}>
37
+ {children}
38
+ </CodeSessionPublishContext.Provider>
39
+ </CodeSessionContext.Provider>
40
+ );
41
+ }
42
+
43
+ export function useCodeSessionContext(): CodeSessionContextValue | null {
44
+ return useContext(CodeSessionContext);
45
+ }
46
+
47
+ export function usePublishCodeSession(): Dispatch<SetStateAction<CodeSessionContextValue | null>> {
48
+ const publish = useContext(CodeSessionPublishContext);
49
+ if (!publish) {
50
+ throw new Error("usePublishCodeSession requires CodeSessionProvider");
51
+ }
52
+ return publish;
53
+ }
@@ -0,0 +1,42 @@
1
+ // packages/studio/src/pages/code-session-files.ts
2
+ export type FileNode =
3
+ | { kind: "file"; name: string; path: string }
4
+ | { kind: "dir"; name: string; path: string; children: FileNode[] };
5
+
6
+ /** One checkout file as the session's container has it now. */
7
+ export type WorkspaceFile =
8
+ | { path: string; content: string }
9
+ | { path: string; binary: true; size: number }
10
+ | { path: string; tooLarge: true; size: number };
11
+
12
+ export function fileTree(paths: readonly string[]): FileNode[] {
13
+ const root: FileNode & { kind: "dir" } = { kind: "dir", name: "", path: "", children: [] };
14
+ for (const path of paths) {
15
+ const parts = path.split("/");
16
+ let dir = root;
17
+ parts.forEach((name, index) => {
18
+ const at = parts.slice(0, index + 1).join("/");
19
+ if (index === parts.length - 1) {
20
+ dir.children.push({ kind: "file", name, path: at });
21
+ return;
22
+ }
23
+ let next = dir.children.find(
24
+ (child): child is FileNode & { kind: "dir" } => child.kind === "dir" && child.name === name,
25
+ );
26
+ if (!next) {
27
+ next = { kind: "dir", name, path: at, children: [] };
28
+ dir.children.push(next);
29
+ }
30
+ dir = next;
31
+ });
32
+ }
33
+ return sortLevel(root.children);
34
+ }
35
+
36
+ function sortLevel(nodes: FileNode[]): FileNode[] {
37
+ return nodes
38
+ .map((node) => (node.kind === "dir" ? { ...node, children: sortLevel(node.children) } : node))
39
+ .sort((a, b) =>
40
+ a.kind !== b.kind ? (a.kind === "dir" ? -1 : 1) : a.name.localeCompare(b.name),
41
+ );
42
+ }
@@ -0,0 +1,52 @@
1
+ // packages/studio/src/pages/code-session-frame.ts
2
+
3
+ /** The host's own origin, and an entry URL carrying the ticket that admits this browser to it. */
4
+ export type HostPreview = { origin: string; entry: string };
5
+
6
+ /** The frame's address. The first load carries the ticket. A reload is that same path. */
7
+ export function frameAddress(preview: HostPreview, path: string, reloads: number): string {
8
+ if (reloads === 0) return previewUrl(preview.entry, path);
9
+ return previewUrl(preview.origin, path);
10
+ }
11
+
12
+ export function previewUrl(base: string, path: string): string {
13
+ const from = new URL(base);
14
+ const url = new URL(path, from.origin);
15
+ from.searchParams.forEach((value, key) => url.searchParams.set(key, value));
16
+ return url.toString();
17
+ }
18
+
19
+ export type PathMemory = {
20
+ getItem(key: string): string | null;
21
+ setItem(key: string, value: string): void;
22
+ };
23
+
24
+ function pathKey(workspaceId: string): string {
25
+ return `backmatter:code-session-path:${workspaceId}`;
26
+ }
27
+
28
+ function isRoute(path: string | null): path is string {
29
+ return typeof path === "string" && path.startsWith("/") && !path.startsWith("//");
30
+ }
31
+
32
+ /** The route this workspace's preview was last on, kept across a reload of Studio. */
33
+ export function rememberedPath(storage: PathMemory, workspaceId: string): string {
34
+ const raw = storage.getItem(pathKey(workspaceId));
35
+ return isRoute(raw) ? raw : "/";
36
+ }
37
+
38
+ export function rememberPath(storage: PathMemory, workspaceId: string, path: string): void {
39
+ if (!isRoute(path)) return;
40
+ storage.setItem(pathKey(workspaceId), path);
41
+ }
42
+
43
+ /** The route a framed host page reports through `postMessage`, if `message` is that report. */
44
+ export function framedLocation(
45
+ message: { origin: string; data: unknown },
46
+ hostOrigin: string,
47
+ ): string | null {
48
+ if (message.origin !== new URL(hostOrigin).origin) return null;
49
+ const data = message.data as { type?: unknown; path?: unknown } | null;
50
+ if (data?.type !== "backmatter:preview-location" || typeof data.path !== "string") return null;
51
+ return data.path.startsWith("/") && !data.path.startsWith("//") ? data.path : null;
52
+ }
@@ -0,0 +1,42 @@
1
+ // packages/studio/src/pages/code-session-routes.ts
2
+ import {
3
+ isRoutable,
4
+ isTemplateSlug,
5
+ matchModelPath,
6
+ routeFor,
7
+ type RouteModel,
8
+ } from "@backmatter/core";
9
+
10
+ export type ContentRouteModel = RouteModel & {
11
+ path: string;
12
+ external?: boolean;
13
+ template?: string;
14
+ };
15
+
16
+ /** Public routes for the content files in a code session checkout. */
17
+ export function contentRoutes(
18
+ models: readonly ContentRouteModel[],
19
+ files: readonly string[],
20
+ ): string[] {
21
+ const routes = new Set<string>();
22
+ for (const file of files) {
23
+ const repoPath = file.replace(/^\.\//, "").replace(/^\/+/, "");
24
+ for (const model of models) {
25
+ const hit = matchModelPath(model, repoPath);
26
+ if (!hit) continue;
27
+ if (isRoutable(model) && !isTemplateSlug(model, hit.slug)) {
28
+ const route = routeFor(model, hit.slug);
29
+ if (route) routes.add(route);
30
+ }
31
+ break;
32
+ }
33
+ }
34
+ return [...routes].sort((a, b) => (a === "/" ? -1 : b === "/" ? 1 : a.localeCompare(b)));
35
+ }
36
+
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[] {
39
+ const needle = query.trim().toLowerCase();
40
+ if (!needle) return [...routes];
41
+ return routes.filter((route) => route.toLowerCase().includes(needle));
42
+ }