@backmatter/studio 0.0.9 → 0.0.11

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.
@@ -0,0 +1,14 @@
1
+ // packages/studio/src/chrome/branch-head.ts
2
+
3
+ /**
4
+ * Whether a later git poll is a new commit.
5
+ * The first sample only records the sha. `updatedAt` is not part of the
6
+ * identity: rewriting the head row must not look like HEAD moved.
7
+ */
8
+ export function branchHeadMoved(
9
+ previousSha: string | null | undefined,
10
+ nextSha: string | null,
11
+ ): boolean {
12
+ if (previousSha === undefined) return false;
13
+ return previousSha !== nextSha;
14
+ }
@@ -49,9 +49,10 @@ import {
49
49
  import {
50
50
  editorPreviewPath,
51
51
  modelForType,
52
- previewSiteUrl,
53
- primarySiteUrl,
54
52
  onSitePreviewUrl,
53
+ previewEnableUrl,
54
+ previewFrameIdentity,
55
+ primarySiteUrl,
55
56
  studioIsOnSite,
56
57
  productionDocUrl,
57
58
  } from "./preview-url";
@@ -255,31 +256,35 @@ export function DocEditorPage() {
255
256
  fieldNames.find((name) => fieldKinds[name] === "blocks") ?? "blocks";
256
257
  const siteUrl = primarySiteUrl(workspace.siteUrls ?? []);
257
258
  const liveUrl = productionDocUrl(siteUrl, modelForType(models, type), locale, slug);
259
+ const previewPath = editorPreviewPath(
260
+ modelForType(models, type),
261
+ slug,
262
+ locale,
263
+ branch,
264
+ tree,
265
+ );
266
+ // Strings only. A replica refresh builds new models and siteUrls arrays
267
+ // with the same contents; that must not mint a ticket and reload the frame.
268
+ const frameIdentity = previewFrameIdentity({
269
+ siteUrl,
270
+ path: showPreview ? previewPath : null,
271
+ branch,
272
+ tree,
273
+ });
258
274
 
259
275
  useEffect(() => {
260
- if (!showPreview) {
261
- setPreviewUrl(null);
262
- return;
263
- }
264
- const redirect = editorPreviewPath(
265
- modelForType(models, type),
266
- slug,
267
- locale,
268
- branch,
269
- tree,
270
- );
271
- if (!redirect || !workspaceId || !siteUrl) {
276
+ if (!frameIdentity || !previewPath || !workspaceId || !siteUrl) {
272
277
  setPreviewUrl(null);
273
278
  return;
274
279
  }
275
280
  if (studioIsOnSite(siteUrl, window.location.origin)) {
276
- setPreviewUrl(onSitePreviewUrl(siteUrl, redirect));
281
+ setPreviewUrl(onSitePreviewUrl(siteUrl, previewPath));
277
282
  return;
278
283
  }
279
284
  let cancelled = false;
280
- void createPreviewTicket(workspaceId, { redirect, branch, tree })
285
+ void createPreviewTicket(workspaceId, { redirect: previewPath, branch, tree })
281
286
  .then((res) => {
282
- if (!cancelled) setPreviewUrl(previewSiteUrl(workspace.siteUrls ?? [], res.ticket));
287
+ if (!cancelled) setPreviewUrl(previewEnableUrl(siteUrl, res.ticket));
283
288
  })
284
289
  .catch(() => {
285
290
  if (!cancelled) setPreviewUrl(null);
@@ -287,7 +292,7 @@ export function DocEditorPage() {
287
292
  return () => {
288
293
  cancelled = true;
289
294
  };
290
- }, [showPreview, workspaceId, workspace.siteUrls, siteUrl, models, type, slug, locale, branch, tree]);
295
+ }, [frameIdentity, previewPath, workspaceId, siteUrl, branch, tree]);
291
296
 
292
297
  useEffect(() => {
293
298
  setPreviewLoading(true);
@@ -539,13 +544,12 @@ export function DocEditorPage() {
539
544
  setDocStatus(detail.status);
540
545
  setSource(detail.status === "clean" ? "head" : "tree");
541
546
  }
542
- // Branch HEAD moved (teammate push / pull / commit). A clean doc reloads to
543
- // show the new HEAD; a doc I am editing keeps my text and just refreshes its
544
- // status, which the server reports as `conflict` when HEAD touched this file.
547
+ // Branch HEAD moved (teammate push / pull / commit). A clean doc reloads its
548
+ // fields. The preview frame stays mounted: the page follows the same move
549
+ // on its SSE stream and refreshes in place.
545
550
  function onHeadChanged() {
546
551
  if (docStatusRef.current === "clean") {
547
552
  onChanged();
548
- remountPreview();
549
553
  return;
550
554
  }
551
555
  void fetchDoc(workspaceId, locale, type, slug)
@@ -32,6 +32,21 @@ export function editorPreviewPath(
32
32
  return `${path}?${params.toString()}`;
33
33
  }
34
34
 
35
+ /**
36
+ * Identity of the preview iframe. Equal results must keep the frame mounted.
37
+ * A replacement models array or siteUrls array is not a new target; the page
38
+ * inside the frame follows saves and HEAD on its SSE stream.
39
+ */
40
+ export function previewFrameIdentity(input: {
41
+ siteUrl: string | null;
42
+ path: string | null;
43
+ branch: string | null;
44
+ tree: string | null;
45
+ }): string | null {
46
+ if (!input.siteUrl || !input.path || !input.branch?.trim() || !input.tree?.trim()) return null;
47
+ return `${input.siteUrl}\0${input.branch}\0${input.tree}\0${input.path}`;
48
+ }
49
+
35
50
  /**
36
51
  * A Studio mounted on the site it previews already carries the site session
37
52
  * cookie, so the preview frame loads the page directly — no ticket.
@@ -12,6 +12,22 @@ import { PREVIEW_FRAME_RADIUS_DEFAULT, previewFrameRadiusFromUserAgent } from ".
12
12
 
13
13
  export const PREVIEW_FRAME_CLASS = "backmatter-preview-frame";
14
14
 
15
+ type NavigatorWithUaData = Navigator & {
16
+ userAgentData?: {
17
+ getHighEntropyValues: (
18
+ hints: readonly string[],
19
+ ) => Promise<{ platformVersion?: string }>;
20
+ };
21
+ };
22
+
23
+ /** Chromium `platformVersion` when Client Hints are available (not in all TS `Navigator` libs). */
24
+ function readNavigatorPlatformVersion(): Promise<string | undefined> {
25
+ if (typeof navigator === "undefined") return Promise.resolve(undefined);
26
+ const uad = (navigator as NavigatorWithUaData).userAgentData;
27
+ if (!uad?.getHighEntropyValues) return Promise.resolve(undefined);
28
+ return uad.getHighEntropyValues(["platformVersion"]).then((hints) => hints.platformVersion);
29
+ }
30
+
15
31
  /** Bottom window corner radius when the host does not set `devBar.previewFrameRadius`. */
16
32
  export function guessPreviewFrameRadius(): number {
17
33
  if (typeof navigator === "undefined") return PREVIEW_FRAME_RADIUS_DEFAULT;
@@ -54,10 +70,9 @@ export function PreviewFrame({
54
70
  const ua = navigator.userAgent;
55
71
  setCorner(previewFrameRadiusFromUserAgent(ua));
56
72
 
57
- const uad = navigator.userAgentData;
58
- if (!uad?.getHighEntropyValues) return;
59
- void uad.getHighEntropyValues(["platformVersion"]).then((hints) => {
60
- setCorner(previewFrameRadiusFromUserAgent(ua, hints.platformVersion));
73
+ void readNavigatorPlatformVersion().then((platformVersion) => {
74
+ if (platformVersion === undefined) return;
75
+ setCorner(previewFrameRadiusFromUserAgent(ua, platformVersion));
61
76
  });
62
77
  }, [radius]);
63
78
 
package/src/site-bar.tsx CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  useMemo,
11
11
  useRef,
12
12
  useState,
13
+ type HTMLAttributes,
13
14
  type MouseEvent,
14
15
  type ReactNode,
15
16
  type Ref,
@@ -74,6 +75,14 @@ function pageMenuCacheKey(workspaceId: string, query: string) {
74
75
 
75
76
  type MenuState = "page" | "branch" | "settings" | null;
76
77
 
78
+ type DevbarHotkeyHandlers = {
79
+ toggleHidden: () => void;
80
+ onModeKey: (origin: HTMLElement) => void;
81
+ onEscape: () => void;
82
+ openDashboard: () => boolean;
83
+ openShortcuts: () => void;
84
+ };
85
+
77
86
  export function SiteBar({
78
87
  label,
79
88
  branch,
@@ -161,7 +170,7 @@ export function SiteBar({
161
170
  const widthRef = useRef(0);
162
171
  const widthReady = useRef(false);
163
172
  const rose = useRef(false);
164
- const handlers = useRef({
173
+ const handlers = useRef<DevbarHotkeyHandlers>({
165
174
  toggleHidden: () => {},
166
175
  onModeKey: (_origin: HTMLElement) => {},
167
176
  onEscape: () => {},
@@ -703,7 +712,17 @@ export function SiteBar({
703
712
  return (
704
713
  <div ref={attachHost} className={`${DEVBAR_CLASS}-host`} data-backmatter-devbar="">
705
714
  <style>{LIGHT_CSS}</style>
706
- {shadow ? createPortal(body, shadow) : <template shadowrootmode="open">{body}</template>}
715
+ {shadow
716
+ ? createPortal(body, shadow)
717
+ : (
718
+ <template
719
+ {...({ shadowrootmode: "open" } as HTMLAttributes<HTMLTemplateElement> & {
720
+ shadowrootmode?: "open" | "closed";
721
+ })}
722
+ >
723
+ {body}
724
+ </template>
725
+ )}
707
726
  </div>
708
727
  );
709
728
  }