@backmatter/next 0.0.23 → 0.0.25

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backmatter/next",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
4
4
  "description": "Experimental Next.js adapter for Backmatter. APIs will break. Not ready for production.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -47,10 +47,10 @@
47
47
  "test": "bun test"
48
48
  },
49
49
  "dependencies": {
50
- "@backmatter/core": "^0.0.20",
50
+ "@backmatter/core": "^0.0.21",
51
51
  "@mdx-js/mdx": "^3.1.1",
52
52
  "remark-gfm": "^4.0.1",
53
- "@backmatter/studio": "^0.0.1"
53
+ "@backmatter/studio": "^0.0.3"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "next": ">=16.3.0",
@@ -8,16 +8,19 @@ import {
8
8
  fetchHostPreviewSession,
9
9
  resolveDevMode,
10
10
  type CmsSessionUser,
11
+ type HostSessionReason,
11
12
  } from "@backmatter/core/preview";
12
13
  import {
13
14
  ENTER_ATTEMPT_KEY,
14
15
  enterPath,
15
- gateOpen,
16
+ hasMarker,
17
+ hasOptInParam,
16
18
  isGateShortcut,
17
19
  markerCookie,
18
20
  recentEnterAttempt,
19
21
  stripOptInParam,
20
22
  } from "./gate";
23
+ import { DEVBAR_CLASS, DEVBAR_ENTER_CSS, devbarShellStyle } from "./devbar-motion";
21
24
 
22
25
  /** The bar and its panels load only after the gate opens. */
23
26
  const SiteChrome = lazy(() => import("./site-editor").then((m) => ({ default: m.SiteChrome })));
@@ -25,7 +28,7 @@ const SiteChrome = lazy(() => import("./site-editor").then((m) => ({ default: m.
25
28
  export type { CmsSessionUser };
26
29
 
27
30
  export type DevModeState =
28
- | { status: "hidden" }
31
+ | { status: "hidden"; reason?: HostSessionReason }
29
32
  | {
30
33
  status: "ready";
31
34
  user?: CmsSessionUser;
@@ -34,11 +37,33 @@ export type DevModeState =
34
37
  tree?: string;
35
38
  };
36
39
 
40
+ export type SessionGateAction = "show" | "bounce" | "quiet" | "wait";
41
+
42
+ /**
43
+ * What the bar does after the site-session check. A verified session shows
44
+ * the bar. An explicit enter (shortcut or `?backmatter=1`) with no session
45
+ * sends the whole window to the dashboard. A leftover marker stays on the
46
+ * public page. An unreachable API waits and keeps the marker.
47
+ */
48
+ export function sessionGateAction(input: {
49
+ explicit: boolean;
50
+ enabled: boolean;
51
+ reason: HostSessionReason;
52
+ recentAttempt: boolean;
53
+ }): SessionGateAction {
54
+ if (input.enabled) return "show";
55
+ if (input.reason === "unavailable") return "wait";
56
+ if (input.explicit && !input.recentAttempt) return "bounce";
57
+ return "quiet";
58
+ }
59
+
37
60
  export function devModeState(
38
61
  user: CmsSessionUser | null,
39
- session: { enabled: boolean; branch?: string; tree?: string },
62
+ session: { enabled: boolean; branch?: string; tree?: string; reason?: HostSessionReason },
40
63
  ): DevModeState {
41
- if (!user && !session.enabled) return { status: "hidden" };
64
+ if (!user && !session.enabled) {
65
+ return session.reason ? { status: "hidden", reason: session.reason } : { status: "hidden" };
66
+ }
42
67
  const resolved = resolveDevMode(session);
43
68
  return {
44
69
  status: "ready",
@@ -77,40 +102,15 @@ export function useDevMode(options: { apiUrl: string }): DevModeState {
77
102
  return state;
78
103
  }
79
104
 
80
- /** Above typical host sticky headers/modals; below max 32-bit int so a host can still overlay. */
81
- const DEVBAR_Z_INDEX = 2147483000;
82
-
83
105
  const barStyle: CSSProperties = {
84
- position: "fixed",
85
- bottom: 0,
86
- left: "50%",
87
- transform: "translateX(-50%)",
88
- zIndex: DEVBAR_Z_INDEX,
89
- isolation: "isolate",
106
+ ...devbarShellStyle,
90
107
  pointerEvents: "auto",
91
- display: "flex",
92
- alignItems: "center",
93
108
  gap: 10,
94
- minHeight: 32,
95
- maxWidth: "min(520px, calc(100vw - 24px))",
96
109
  padding: "0 12px",
97
- paddingBottom: "env(safe-area-inset-bottom, 0px)",
98
- background: "#f4f4f5",
99
- color: "#71717a",
100
- border: "1px solid #e4e4e7",
101
- borderBottom: "none",
102
- borderTopLeftRadius: 8,
103
- borderTopRightRadius: 8,
104
- borderBottomLeftRadius: 0,
105
- borderBottomRightRadius: 0,
106
- fontFamily:
107
- 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
108
- fontSize: 11,
110
+ fontFamily: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
111
+ fontSize: 12,
109
112
  fontWeight: 500,
110
- lineHeight: "32px",
111
- WebkitFontSmoothing: "antialiased",
112
- fontVariantNumeric: "tabular-nums",
113
- boxSizing: "border-box",
113
+ lineHeight: "44px",
114
114
  };
115
115
 
116
116
  const brandStyle: CSSProperties = {
@@ -151,8 +151,8 @@ const exitButtonStyle: CSSProperties = {
151
151
  fontFamily: "inherit",
152
152
  fontSize: 11,
153
153
  fontWeight: 500,
154
- lineHeight: "32px",
155
- minHeight: 32,
154
+ lineHeight: "44px",
155
+ minHeight: 44,
156
156
  touchAction: "manipulation",
157
157
  WebkitFontSmoothing: "antialiased",
158
158
  };
@@ -217,8 +217,9 @@ export function DevBarView({
217
217
  const ariaLabel = parts.join(", ");
218
218
 
219
219
  return (
220
- <div role="status" aria-label={ariaLabel} style={barStyle}>
220
+ <div role="status" aria-label={ariaLabel} className={DEVBAR_CLASS} style={barStyle}>
221
221
  <style>{`
222
+ ${DEVBAR_ENTER_CSS}
222
223
  @media (hover: hover) {
223
224
  button.backmatter-devbar-exit:hover:not(:disabled) { color: #71717a; }
224
225
  }
@@ -289,12 +290,12 @@ export function DevBarView({
289
290
 
290
291
  const DEFAULT_PREVIEW_ENTRY_PATH = "/api/backmatter/preview";
291
292
 
292
- type Gate = "closed" | "open";
293
+ type Gate = "closed" | "resume" | "enter";
293
294
 
294
295
  /**
295
- * Opt-in check with no network: `?backmatter=1` (then stripped from the URL and
296
- * remembered in the marker cookie), the marker itself, or the desktop
297
- * shortcut. Public visitors stop here.
296
+ * Opt-in check with no network. Public visitors stop here. `?backmatter=1`
297
+ * and the shortcut are an explicit enter. The marker only resumes a session
298
+ * that is still valid.
298
299
  */
299
300
  function useGate(): Gate {
300
301
  const [gate, setGate] = useState<Gate>("closed");
@@ -304,18 +305,19 @@ function useGate(): Gate {
304
305
  // view): that editor is the chrome, so the page shows no bar of its own.
305
306
  if (window.self !== window.top) return;
306
307
  const secure = location.protocol === "https:";
307
- if (gateOpen({ search: location.search, cookie: document.cookie })) {
308
+ const explicit = hasOptInParam(location.search);
309
+ if (explicit || hasMarker(document.cookie)) {
308
310
  document.cookie = markerCookie(true, secure);
309
311
  const clean = stripOptInParam(location.href);
310
312
  if (clean) history.replaceState(history.state, "", clean);
311
- setGate("open");
313
+ setGate(explicit ? "enter" : "resume");
312
314
  return;
313
315
  }
314
316
  const onKey = (e: KeyboardEvent) => {
315
317
  if (!isGateShortcut(e)) return;
316
318
  e.preventDefault();
317
319
  document.cookie = markerCookie(true, secure);
318
- setGate("open");
320
+ setGate("enter");
319
321
  };
320
322
  window.addEventListener("keydown", onKey);
321
323
  return () => window.removeEventListener("keydown", onKey);
@@ -325,32 +327,43 @@ function useGate(): Gate {
325
327
  }
326
328
 
327
329
  /**
328
- * Past the gate: ask this origin for the session. None means a top-level
329
- * bounce to the dashboard, which returns here with a ticket. A return without
330
- * a session (login abandoned, site not in the workspace) clears the marker
331
- * instead of bouncing again.
330
+ * Past the gate: ask this origin whether the site session still verifies.
331
+ * Only an explicit enter with no session leaves the page, and it leaves
332
+ * top-level. A stale marker stays on the public page.
332
333
  */
333
334
  function useSiteSession(gate: Gate, previewEntryPath: string): DevModeState {
334
335
  const [state, setState] = useState<DevModeState>({ status: "hidden" });
335
336
 
336
337
  useEffect(() => {
337
- if (gate !== "open") return;
338
+ if (gate === "closed") return;
338
339
  let cancelled = false;
339
340
  void loadDevModeState("").then((next) => {
340
341
  if (cancelled) return;
341
- if (next.status === "ready" && next.mode === "preview") {
342
+ const enabled = next.status === "ready" && next.mode === "preview";
343
+ const reason = next.status === "hidden" ? (next.reason ?? "anonymous") : "anonymous";
344
+ const action = sessionGateAction({
345
+ explicit: gate === "enter",
346
+ enabled,
347
+ reason,
348
+ recentAttempt: recentEnterAttempt(sessionStorage.getItem(ENTER_ATTEMPT_KEY), Date.now()),
349
+ });
350
+ if (action === "show") {
342
351
  sessionStorage.removeItem(ENTER_ATTEMPT_KEY);
343
352
  setState(next);
344
353
  return;
345
354
  }
346
- if (recentEnterAttempt(sessionStorage.getItem(ENTER_ATTEMPT_KEY), Date.now())) {
347
- sessionStorage.removeItem(ENTER_ATTEMPT_KEY);
348
- document.cookie = markerCookie(false, location.protocol === "https:");
349
- setState({ status: "hidden" });
355
+ if (action === "wait") {
356
+ setState({ status: "hidden", reason: "unavailable" });
357
+ return;
358
+ }
359
+ if (action === "bounce") {
360
+ sessionStorage.setItem(ENTER_ATTEMPT_KEY, String(Date.now()));
361
+ location.assign(enterPath(previewEntryPath, location.pathname + location.search + location.hash));
350
362
  return;
351
363
  }
352
- sessionStorage.setItem(ENTER_ATTEMPT_KEY, String(Date.now()));
353
- location.assign(enterPath(previewEntryPath, location.pathname + location.search + location.hash));
364
+ sessionStorage.removeItem(ENTER_ATTEMPT_KEY);
365
+ document.cookie = markerCookie(false, location.protocol === "https:");
366
+ setState({ status: "hidden", reason });
354
367
  });
355
368
  return () => {
356
369
  cancelled = true;
@@ -389,6 +402,7 @@ export function DevBarClient({
389
402
  exiting={exiting}
390
403
  onExit={exit}
391
404
  studioPath={studioPath}
405
+ previewEntryPath={previewEntryPath}
392
406
  />
393
407
  </Suspense>
394
408
  );
@@ -0,0 +1,58 @@
1
+ // packages/next/src/devbar-motion.ts
2
+ import type { CSSProperties } from "react";
3
+
4
+ /** Above typical host sticky headers; below the max 32-bit int so a host can still overlay. */
5
+ export const DEVBAR_Z_INDEX = 2147483000;
6
+
7
+ export const DEVBAR_CLASS = "backmatter-devbar";
8
+
9
+ /** Space panels need above the corner bar: 16px inset + 44px bar + 12px gap. */
10
+ export const DEVBAR_CLEARANCE =
11
+ "calc(max(16px, env(safe-area-inset-bottom, 0px)) + 56px)";
12
+
13
+ export const DEVBAR_RIGHT = "max(16px, env(safe-area-inset-right, 0px))";
14
+
15
+ /** Shared chrome for the corner bar. Resting position does not use transform, so the enter animation can. */
16
+ export const devbarShellStyle: CSSProperties = {
17
+ position: "fixed",
18
+ right: DEVBAR_RIGHT,
19
+ bottom: "max(16px, env(safe-area-inset-bottom, 0px))",
20
+ zIndex: DEVBAR_Z_INDEX,
21
+ isolation: "isolate",
22
+ display: "flex",
23
+ alignItems: "center",
24
+ width: "max-content",
25
+ maxWidth: "calc(100vw - 32px)",
26
+ minHeight: 44,
27
+ boxSizing: "border-box",
28
+ border: "1px solid #e4e4e7",
29
+ borderRadius: 12,
30
+ background: "#ffffff",
31
+ color: "#71717a",
32
+ boxShadow: "0 10px 28px rgba(24, 24, 27, 0.12), 0 1px 2px rgba(24, 24, 27, 0.06)",
33
+ transformOrigin: "100% 100%",
34
+ WebkitFontSmoothing: "antialiased",
35
+ fontVariantNumeric: "tabular-nums",
36
+ };
37
+
38
+ /** Plays when the bar mounts — that is when the session is ready, not on first paint. */
39
+ export const DEVBAR_ENTER_CSS = `
40
+ @keyframes backmatter-devbar-in {
41
+ from {
42
+ opacity: 0;
43
+ transform: translateY(10px) scale(0.98);
44
+ }
45
+ to {
46
+ opacity: 1;
47
+ transform: none;
48
+ }
49
+ }
50
+ .${DEVBAR_CLASS} {
51
+ animation: backmatter-devbar-in 240ms cubic-bezier(0.23, 1, 0.32, 1) both;
52
+ }
53
+ @media (prefers-reduced-motion: reduce) {
54
+ .${DEVBAR_CLASS} {
55
+ animation: none;
56
+ }
57
+ }
58
+ `;
package/src/devbar.tsx CHANGED
@@ -8,16 +8,20 @@ export {
8
8
  devModeState,
9
9
  exitHostPreview,
10
10
  loadDevModeState,
11
+ sessionGateAction,
11
12
  useDevMode,
12
13
  type CmsSessionUser,
13
14
  type DevModeState,
15
+ type SessionGateAction,
14
16
  } from "./devbar-client";
15
17
 
16
18
  /**
17
19
  * Host layout: `<DevBar />`. Public visitors get a gate of a few hundred
18
- * bytes that makes no request; `?backmatter=1`, the `bm_ui` marker cookie or
19
- * ⌘⇧E opens it. `studioPath` is where `@backmatter/studio` is mounted on this
20
- * site (default `/backmatter`); the bar's panels are frames of that route.
20
+ * bytes that makes no request. The bar appears only when the site session
21
+ * verifies. `?backmatter=1` or ⌘⇧E with no session sends the whole window to
22
+ * the dashboard; a leftover `bm_ui` marker stays on the public page.
23
+ * `studioPath` is where `@backmatter/studio` is mounted on this site
24
+ * (default `/backmatter`); the bar's panels are frames of that route.
21
25
  * `previewEntryPath` is the ticket route (default `/api/backmatter/preview`).
22
26
  * `apiUrl` is accepted for older layouts and no longer used.
23
27
  */
@@ -87,7 +87,7 @@ export async function renderPage(
87
87
  ): Promise<ReactNode> {
88
88
  const blocks = asBlocks(doc.frontmatter.blocks);
89
89
  const children = await Promise.all(
90
- blocks.map(async (block) => {
90
+ blocks.map(async (block, index) => {
91
91
  if (typeof block.type !== "string") return null;
92
92
  const entry = components?.[block.type];
93
93
  if (!entry) {
@@ -107,8 +107,18 @@ export async function renderPage(
107
107
  console.warn(`[backmatter] skipping unknown block type "${block.type}"`);
108
108
  return null;
109
109
  }
110
- const key = typeof block.id === "string" ? block.id : undefined;
111
- return createElement(Cmp, { key, ...props, [RESERVED_BLOCK_PROP]: data });
110
+ const id = typeof block.id === "string" && block.id ? block.id : String(index);
111
+ return createElement(
112
+ "div",
113
+ {
114
+ key: id,
115
+ "data-backmatter-block": id,
116
+ "data-backmatter-type": block.type,
117
+ "data-backmatter-index": String(index),
118
+ style: { display: "contents" },
119
+ },
120
+ createElement(Cmp, { ...props, [RESERVED_BLOCK_PROP]: data }),
121
+ );
112
122
  }),
113
123
  );
114
124
  const rendered = children.filter((child) => child != null);
@@ -7,6 +7,7 @@ import {
7
7
  PREVIEW_SESSION_TTL_MS,
8
8
  previewLocationWithCheckout,
9
9
  readPreviewCookie,
10
+ readPreviewSession,
10
11
  readPreviewTicket,
11
12
  resolveDashboardUrl,
12
13
  studioEnterUrl,
@@ -25,6 +26,17 @@ export type PreviewRouteDeps = {
25
26
  now?: () => number;
26
27
  /** Dashboard that holds the login, for `?enter=1`. Default: `resolveDashboardUrl()`. */
27
28
  dashboardUrl?: string;
29
+ /**
30
+ * Exchange the current site session for a fresh token. Default calls
31
+ * `POST /preview/session/renew` on the API. Null means the session is dead.
32
+ * Throw when the API cannot be reached; that becomes a 503 and keeps the cookies.
33
+ */
34
+ renewSession?: (token: string) => Promise<string | null>;
35
+ /**
36
+ * Ask the API whether this site-session token is still logged in, epoch
37
+ * included. Default calls `GET /preview/session`.
38
+ */
39
+ confirmSession?: (token: string) => Promise<"ok" | "invalid" | "unavailable">;
28
40
  };
29
41
 
30
42
  /** The gate's marker: readable by the page script, so never HttpOnly. */
@@ -73,6 +85,42 @@ async function nextDraftEnabled(): Promise<boolean> {
73
85
  return (await mod.draftMode()).isEnabled;
74
86
  }
75
87
 
88
+ async function renewSessionOnApi(apiUrl: string, token: string): Promise<string | null> {
89
+ let res: Response;
90
+ try {
91
+ res = await fetch(`${apiUrl}/preview/session/renew`, {
92
+ method: "POST",
93
+ headers: { authorization: `Bearer ${token}` },
94
+ cache: "no-store",
95
+ });
96
+ } catch {
97
+ throw new Error("renew unavailable");
98
+ }
99
+ if (res.status === 401 || res.status === 403) return null;
100
+ if (!res.ok) throw new Error(`renew unavailable: ${res.status}`);
101
+ const body = (await res.json()) as { token?: unknown };
102
+ return typeof body.token === "string" && body.token ? body.token : null;
103
+ }
104
+
105
+ async function confirmSessionOnApi(
106
+ apiUrl: string | undefined,
107
+ token: string,
108
+ ): Promise<"ok" | "invalid" | "unavailable"> {
109
+ const base = apiUrl?.replace(/\/$/, "");
110
+ if (!base) return "unavailable";
111
+ try {
112
+ const res = await fetch(`${base}/preview/session`, {
113
+ headers: { authorization: `Bearer ${token}` },
114
+ cache: "no-store",
115
+ });
116
+ if (res.status === 401 || res.status === 403) return "invalid";
117
+ if (!res.ok) return "unavailable";
118
+ return "ok";
119
+ } catch {
120
+ return "unavailable";
121
+ }
122
+ }
123
+
76
124
  function cookieFrom(req: Request, name: string): string | undefined {
77
125
  const header = req.headers.get("cookie");
78
126
  if (!header) return undefined;
@@ -102,15 +150,39 @@ export function createPreviewRoute(
102
150
  const secure = url.protocol === "https:";
103
151
 
104
152
  if (url.searchParams.get("session") === "1") {
105
- const checkout = secret ? await checkoutFrom(req, secret, url) : null;
106
- if (checkout) {
107
- return Response.json({
108
- mode: "preview",
109
- ...checkout,
110
- });
153
+ // The bar's only login signal. The checkout cookie and draft mode still
154
+ // render drafts; they do not mean the visitor is signed in.
155
+ const token = cookieFrom(req, PREVIEW_SESSION_COOKIE);
156
+ const claims = secret && token ? await readPreviewSession(secret, token) : null;
157
+ const workspaceOk = !cms.config.workspaceId || claims?.workspaceId === cms.config.workspaceId;
158
+ const clearLogin = async () => {
159
+ await (deps.disableDraft ?? (() => nextDraft(false)))();
160
+ const headers = new Headers({ "Cache-Control": "no-store" });
161
+ headers.append("Set-Cookie", cookieBase(PREVIEW_COOKIE, secure, 0, ""));
162
+ headers.append("Set-Cookie", cookieBase(PREVIEW_SESSION_COOKIE, secure, 0, ""));
163
+ headers.append("Set-Cookie", markerCookieHeader(secure, false));
164
+ return Response.json({ mode: "live", reason: "invalid" }, { headers });
165
+ };
166
+ if (!claims || !token || !workspaceOk) {
167
+ if (token) return clearLogin();
168
+ return Response.json(
169
+ { mode: "live", reason: "anonymous" },
170
+ { headers: { "Cache-Control": "no-store" } },
171
+ );
172
+ }
173
+ const confirm = deps.confirmSession ?? ((current: string) => confirmSessionOnApi(cms.config.apiUrl, current));
174
+ const verdict = await confirm(token);
175
+ if (verdict === "unavailable") {
176
+ return Response.json(
177
+ { mode: "live", reason: "unavailable" },
178
+ { headers: { "Cache-Control": "no-store" } },
179
+ );
111
180
  }
112
- const draftOn = await (deps.isDraftEnabled ?? nextDraftEnabled)();
113
- return Response.json({ mode: draftOn ? "preview" : "live" });
181
+ if (verdict !== "ok") return clearLogin();
182
+ return Response.json(
183
+ { mode: "preview", branch: claims.branch, tree: claims.tree },
184
+ { headers: { "Cache-Control": "no-store" } },
185
+ );
114
186
  }
115
187
 
116
188
  // The bar found the marker but no session: go top-level to the dashboard,
@@ -210,6 +282,34 @@ export function createPreviewRoute(
210
282
  };
211
283
 
212
284
  const POST = async (req: Request): Promise<Response> => {
285
+ const url = new URL(req.url);
286
+ // Slide the site session while the bar is open. The cookie is HttpOnly, so
287
+ // the browser cannot renew it itself; a dead session is a 401 and the bar
288
+ // bounces to the dashboard instead of failing the next chat send.
289
+ if (url.searchParams.get("renew") === "1") {
290
+ const token = cookieFrom(req, PREVIEW_SESSION_COOKIE);
291
+ const apiUrl = cms.config.apiUrl?.replace(/\/$/, "");
292
+ if (!token || !apiUrl) return Response.json({ error: "unauthorized" }, { status: 401 });
293
+ let renewed: string | null;
294
+ try {
295
+ renewed = await (deps.renewSession ?? ((current) => renewSessionOnApi(apiUrl, current)))(token);
296
+ } catch {
297
+ return Response.json({ error: "unavailable" }, { status: 503 });
298
+ }
299
+ if (!renewed) return Response.json({ error: "unauthorized" }, { status: 401 });
300
+ const headers = new Headers();
301
+ headers.append(
302
+ "Set-Cookie",
303
+ cookieBase(
304
+ PREVIEW_SESSION_COOKIE,
305
+ url.protocol === "https:",
306
+ Math.floor(PREVIEW_SESSION_TTL_MS / 1000),
307
+ renewed,
308
+ ),
309
+ );
310
+ return new Response(null, { status: 204, headers });
311
+ }
312
+
213
313
  await (deps.disableDraft ?? (() => nextDraft(false)))();
214
314
  const secure = new URL(req.url).protocol === "https:";
215
315
  const headers = new Headers();
@@ -3,8 +3,21 @@
3
3
 
4
4
  import { usePathname, useRouter } from "next/navigation";
5
5
  import { startTransition, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
6
+ import {
7
+ DEVBAR_CLASS,
8
+ DEVBAR_CLEARANCE,
9
+ DEVBAR_ENTER_CSS,
10
+ DEVBAR_RIGHT,
11
+ devbarShellStyle,
12
+ } from "./devbar-motion";
13
+ import { markerCookie } from "./gate";
6
14
  import { subscribeSiteDoc } from "./site-doc-bridge";
7
15
  import { readSiteDoc, sameSiteDoc, type SiteDocRef } from "./site-doc";
16
+ import { installSitePointer } from "./site-pointer";
17
+
18
+ const DEFAULT_PREVIEW_ENTRY_PATH = "/api/backmatter/preview";
19
+ /** Slide the 12h site session while the bar is on screen. */
20
+ const SESSION_RENEW_MS = 30 * 60 * 1000;
8
21
 
9
22
  function useSiteDoc(): SiteDocRef | null {
10
23
  const pathname = usePathname();
@@ -51,6 +64,7 @@ export function SiteChrome({
51
64
  exiting,
52
65
  onExit,
53
66
  studioPath = DEFAULT_STUDIO_PATH,
67
+ previewEntryPath = DEFAULT_PREVIEW_ENTRY_PATH,
54
68
  }: {
55
69
  branch?: string;
56
70
  tree?: string;
@@ -58,12 +72,13 @@ export function SiteChrome({
58
72
  onExit: () => void;
59
73
  /** Where `@backmatter/studio` is mounted on this site. */
60
74
  studioPath?: string;
75
+ /** Ticket route. `POST ?renew=1` slides the site session. A 401 drops the bar. */
76
+ previewEntryPath?: string;
61
77
  }) {
62
78
  const router = useRouter();
63
79
  const doc = useSiteDoc();
64
80
  const [editorMounted, setEditorMounted] = useState(false);
65
81
  const [editorOpen, setEditorOpen] = useState(false);
66
- const [chatMounted, setChatMounted] = useState(false);
67
82
  const [chatOpen, setChatOpen] = useState(false);
68
83
  const [publishOpen, setPublishOpen] = useState(false);
69
84
  const [changeCount, setChangeCount] = useState(0);
@@ -80,7 +95,6 @@ export function SiteChrome({
80
95
  function onKey(event: KeyboardEvent) {
81
96
  if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "j") return;
82
97
  event.preventDefault();
83
- setChatMounted(true);
84
98
  setChatOpen((open) => !open);
85
99
  }
86
100
  document.addEventListener("keydown", onKey, true);
@@ -105,7 +119,6 @@ export function SiteChrome({
105
119
  data.key?.toLowerCase() === "j" &&
106
120
  (data.metaKey || data.ctrlKey)
107
121
  ) {
108
- setChatMounted(true);
109
122
  setChatOpen((open) => !open);
110
123
  }
111
124
  if (data?.type === "backmatter:published") {
@@ -119,6 +132,48 @@ export function SiteChrome({
119
132
  return () => window.removeEventListener("message", onMessage);
120
133
  }, [origin, router]);
121
134
 
135
+ useEffect(() => {
136
+ let stopped = false;
137
+ let left = false;
138
+ function leaveEditor() {
139
+ if (left || stopped) return;
140
+ left = true;
141
+ void (async () => {
142
+ try {
143
+ await fetch(previewEntryPath, { method: "POST", credentials: "same-origin", cache: "no-store" });
144
+ } catch {
145
+ // The cookies expire on their own. Still drop the bar.
146
+ }
147
+ document.cookie = markerCookie(false, location.protocol === "https:");
148
+ location.reload();
149
+ })();
150
+ }
151
+ async function renew() {
152
+ try {
153
+ const res = await fetch(`${previewEntryPath}?renew=1`, {
154
+ method: "POST",
155
+ credentials: "same-origin",
156
+ cache: "no-store",
157
+ });
158
+ if (res.status === 401) leaveEditor();
159
+ } catch {
160
+ // Offline. The next send, or the next interval, tries again.
161
+ }
162
+ }
163
+ void renew();
164
+ const timer = setInterval(() => void renew(), SESSION_RENEW_MS);
165
+ function onExpired(event: MessageEvent) {
166
+ if (event.origin !== origin) return;
167
+ if (event.data?.type === "backmatter:session-expired") leaveEditor();
168
+ }
169
+ window.addEventListener("message", onExpired);
170
+ return () => {
171
+ stopped = true;
172
+ clearInterval(timer);
173
+ window.removeEventListener("message", onExpired);
174
+ };
175
+ }, [origin, previewEntryPath]);
176
+
122
177
  const label = doc ? `${doc.type}/${doc.slug}` : "No document";
123
178
  const editorSrc =
124
179
  doc && base
@@ -132,7 +187,33 @@ export function SiteChrome({
132
187
 
133
188
  return (
134
189
  <>
135
- <style>{"body{padding-bottom:36px}"}</style>
190
+ <style>{`
191
+ ${DEVBAR_ENTER_CSS}
192
+ @media (hover: hover) {
193
+ .${DEVBAR_CLASS} button:hover:not(:disabled) {
194
+ background: #f4f4f5;
195
+ }
196
+ }
197
+ .${DEVBAR_CLASS} button:focus-visible {
198
+ outline: 2px solid #18181b;
199
+ outline-offset: 2px;
200
+ }
201
+ .${DEVBAR_CLASS} button:disabled {
202
+ cursor: default;
203
+ opacity: 0.5;
204
+ }
205
+ html.backmatter-pointing,
206
+ html.backmatter-pointing * {
207
+ cursor: crosshair !important;
208
+ }
209
+ html.backmatter-pointing .${DEVBAR_CLASS},
210
+ html.backmatter-pointing .${DEVBAR_CLASS} * {
211
+ cursor: pointer !important;
212
+ }
213
+ html.backmatter-pointing .${DEVBAR_CLASS} button:disabled {
214
+ cursor: default !important;
215
+ }
216
+ `}</style>
136
217
  {editorMounted && doc && editorSrc ? (
137
218
  <iframe
138
219
  ref={editorFrame}
@@ -145,7 +226,7 @@ export function SiteChrome({
145
226
  }}
146
227
  />
147
228
  ) : null}
148
- {chatMounted && doc ? (
229
+ {doc && base ? (
149
230
  <CmsChat
150
231
  doc={doc}
151
232
  hidden={!chatOpen}
@@ -165,7 +246,7 @@ export function SiteChrome({
165
246
  }}
166
247
  />
167
248
  ) : null}
168
- <footer aria-label="Backmatter" style={barStyle}>
249
+ <footer aria-label="Backmatter" className={DEVBAR_CLASS} style={barStyle}>
169
250
  <button
170
251
  type="button"
171
252
  aria-pressed={editorOpen}
@@ -181,17 +262,13 @@ export function SiteChrome({
181
262
  <BarText>Preview</BarText>
182
263
  {branch ? <BarText>{branch}</BarText> : null}
183
264
  {tree ? <BarText>draft</BarText> : null}
184
- <span style={{ flex: 1 }} />
185
265
  <button type="button" aria-pressed={publishOpen} onClick={() => setPublishOpen((open) => !open)} style={barButton}>
186
266
  Publish{changeCount ? ` ${changeCount}` : ""}
187
267
  </button>
188
268
  <button
189
269
  type="button"
190
270
  aria-pressed={chatOpen}
191
- onClick={() => {
192
- setChatMounted(true);
193
- setChatOpen((open) => !open);
194
- }}
271
+ onClick={() => setChatOpen((open) => !open)}
195
272
  title="Toggle AI chat (⌘J)"
196
273
  style={barButton}
197
274
  >
@@ -239,6 +316,9 @@ function CmsChat({
239
316
  if (!hidden) openChat();
240
317
  }, [hidden, origin]);
241
318
 
319
+ const docRef = useRef(doc);
320
+ docRef.current = doc;
321
+
242
322
  useEffect(() => {
243
323
  const focus = { locale: doc.locale, type: doc.type, slug: doc.slug };
244
324
  function postFocus() {
@@ -258,6 +338,41 @@ function CmsChat({
258
338
  return () => window.removeEventListener("message", onMessage);
259
339
  }, [doc.locale, doc.type, doc.slug, origin]);
260
340
 
341
+ useEffect(() => {
342
+ if (!origin) return;
343
+ const pointer = installSitePointer(document, {
344
+ bar: () => document.querySelector(`.${DEVBAR_CLASS}`),
345
+ ignore: (target) => {
346
+ if (!(target instanceof Element)) return true;
347
+ const frameEl = frame.current;
348
+ if (frameEl && (target === frameEl || frameEl.contains(target))) return true;
349
+ return target.closest("iframe") !== null;
350
+ },
351
+ onGrab: (grab) => {
352
+ frame.current?.contentWindow?.postMessage(
353
+ { type: "backmatter:pointer-grab", grab, doc: docRef.current },
354
+ origin,
355
+ );
356
+ },
357
+ onEscape: () => {
358
+ frame.current?.contentWindow?.postMessage({ type: "backmatter:pointer", on: false }, origin);
359
+ },
360
+ });
361
+ function onMessage(event: MessageEvent) {
362
+ if (event.origin !== origin || event.source !== frame.current?.contentWindow) return;
363
+ const data = event.data as { type?: string; on?: boolean };
364
+ if (data?.type !== "backmatter:pointer" || typeof data.on !== "boolean") return;
365
+ if (data.on && !hiddenRef.current) pointer.arm();
366
+ else pointer.disarm();
367
+ }
368
+ window.addEventListener("message", onMessage);
369
+ return () => {
370
+ window.removeEventListener("message", onMessage);
371
+ pointer.dispose();
372
+ frame.current?.contentWindow?.postMessage({ type: "backmatter:pointer", on: false }, origin);
373
+ };
374
+ }, [origin, hidden]);
375
+
261
376
  if (!base) return null;
262
377
  return (
263
378
  <iframe
@@ -278,48 +393,51 @@ function BarText({ children }: { children: ReactNode }) {
278
393
  }
279
394
 
280
395
  const barStyle: CSSProperties = {
281
- position: "fixed",
282
- left: 0,
283
- right: 0,
284
- bottom: 0,
285
- zIndex: 2147483000,
286
- display: "flex",
287
- alignItems: "center",
288
- gap: 12,
289
- height: 36,
290
- padding: "0 12px",
291
- paddingBottom: "env(safe-area-inset-bottom, 0px)",
292
- boxSizing: "border-box",
293
- borderTop: "1px solid #e4e4e7",
294
- background: "#f4f4f5",
295
- color: "#71717a",
296
- font: "12px/1 ui-sans-serif, system-ui, sans-serif",
396
+ ...devbarShellStyle,
397
+ gap: 2,
398
+ padding: "0 6px 0 10px",
399
+ font: '12px/1 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
297
400
  };
298
401
 
299
402
  const barButton: CSSProperties = {
300
403
  border: 0,
404
+ borderRadius: 8,
301
405
  background: "transparent",
302
406
  color: "#18181b",
303
407
  font: "inherit",
304
408
  cursor: "pointer",
305
- padding: "4px 6px",
409
+ padding: "0 10px",
410
+ minHeight: 44,
306
411
  };
307
412
 
308
413
  const docButton: CSSProperties = {
309
414
  ...barButton,
310
415
  fontWeight: 600,
416
+ maxWidth: 200,
417
+ overflow: "hidden",
418
+ textOverflow: "ellipsis",
419
+ whiteSpace: "nowrap",
311
420
  };
312
421
 
313
- const mutedStyle: CSSProperties = { color: "#71717a", fontSize: 12, margin: 0 };
422
+ const mutedStyle: CSSProperties = {
423
+ color: "#71717a",
424
+ fontSize: 12,
425
+ margin: 0,
426
+ padding: "0 6px",
427
+ maxWidth: 140,
428
+ overflow: "hidden",
429
+ textOverflow: "ellipsis",
430
+ whiteSpace: "nowrap",
431
+ };
314
432
 
315
433
  const editorStyle: CSSProperties = {
316
434
  position: "fixed",
317
435
  left: 0,
318
436
  right: 0,
319
- bottom: 36,
437
+ bottom: DEVBAR_CLEARANCE,
320
438
  zIndex: 2147482999,
321
439
  width: "100%",
322
- height: "min(78vh, calc(100vh - 48px))",
440
+ height: "min(78vh, calc(100vh - 104px))",
323
441
  border: 0,
324
442
  borderTop: "1px solid #e4e4e7",
325
443
  background: "#ffffff",
@@ -328,11 +446,11 @@ const editorStyle: CSSProperties = {
328
446
 
329
447
  const chatStyle: CSSProperties = {
330
448
  position: "fixed",
331
- right: 12,
332
- bottom: 48,
449
+ right: DEVBAR_RIGHT,
450
+ bottom: DEVBAR_CLEARANCE,
333
451
  zIndex: 2147483001,
334
- width: "min(440px, calc(100vw - 24px))",
335
- height: "min(640px, calc(100vh - 120px))",
452
+ width: "min(440px, calc(100vw - 32px))",
453
+ height: "min(640px, calc(100vh - 148px))",
336
454
  border: 0,
337
455
  borderRadius: 14,
338
456
  background: "#f4f4f5",
@@ -341,11 +459,11 @@ const chatStyle: CSSProperties = {
341
459
 
342
460
  const publishStyle: CSSProperties = {
343
461
  position: "fixed",
344
- right: 12,
345
- bottom: 48,
462
+ right: DEVBAR_RIGHT,
463
+ bottom: DEVBAR_CLEARANCE,
346
464
  zIndex: 2147483001,
347
- width: "min(420px, calc(100vw - 24px))",
348
- height: "min(520px, calc(100vh - 120px))",
465
+ width: "min(420px, calc(100vw - 32px))",
466
+ height: "min(520px, calc(100vh - 148px))",
349
467
  border: "1px solid #e4e4e7",
350
468
  borderRadius: 8,
351
469
  background: "#ffffff",
@@ -0,0 +1,199 @@
1
+ // packages/next/src/site-pointer.ts
2
+ // Hit-test a click on the host page and draw the armed pointer outline.
3
+
4
+ export const POINTER_QUOTE_MAX = 240;
5
+
6
+ export type PointerGrab = {
7
+ blockId: string | null;
8
+ blockType: string | null;
9
+ blockIndex: number | null;
10
+ quote: string;
11
+ };
12
+
13
+ function quoteOf(target: Element): string {
14
+ return (target.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, POINTER_QUOTE_MAX);
15
+ }
16
+
17
+ function presentAttr(el: Element, name: string): string | null {
18
+ const value = el.getAttribute(name);
19
+ return value ? value : null;
20
+ }
21
+
22
+ /** Resolve the block marker and visible text under a click. Clicks on the bar are ignored. */
23
+ export function grabAt(target: Element | null, bar: Element | null): PointerGrab | null {
24
+ if (!target) return null;
25
+ const doc = target.ownerDocument;
26
+ if (target === doc.documentElement || target === doc.body) return null;
27
+ if (bar && (target === bar || bar.contains(target))) return null;
28
+ if (target.closest("[data-backmatter-pointer]")) return null;
29
+
30
+ const marker = target.closest("[data-backmatter-block]");
31
+ const quote = quoteOf(target);
32
+ if (!marker) {
33
+ if (!quote) return null;
34
+ return { blockId: null, blockType: null, blockIndex: null, quote };
35
+ }
36
+ const indexRaw = marker.getAttribute("data-backmatter-index");
37
+ const blockIndex = indexRaw !== null && /^\d+$/.test(indexRaw) ? Number(indexRaw) : null;
38
+ return {
39
+ blockId: presentAttr(marker, "data-backmatter-block"),
40
+ blockType: presentAttr(marker, "data-backmatter-type"),
41
+ blockIndex,
42
+ quote,
43
+ };
44
+ }
45
+
46
+ export type SitePointer = {
47
+ arm: () => void;
48
+ disarm: () => void;
49
+ dispose: () => void;
50
+ };
51
+
52
+ /**
53
+ * While armed, the page shows a crosshair and an outline. A click reports the
54
+ * element and does not follow links. Escape disarms. The picker stays armed
55
+ * after a click so another element can be added.
56
+ */
57
+ export function installSitePointer(
58
+ doc: Document,
59
+ options: {
60
+ bar: () => Element | null;
61
+ ignore?: (target: EventTarget | null) => boolean;
62
+ onGrab: (grab: PointerGrab) => void;
63
+ onEscape?: () => void;
64
+ },
65
+ ): SitePointer {
66
+ let armed = false;
67
+ let current: Element | null = null;
68
+ let outline: HTMLDivElement | null = null;
69
+ const root = doc.documentElement;
70
+
71
+ function ignored(target: EventTarget | null): boolean {
72
+ if (!(target instanceof Element)) return true;
73
+ if (target === root || target === doc.body) return true;
74
+ if (target.closest("[data-backmatter-pointer]")) return true;
75
+ const bar = options.bar();
76
+ if (bar && (target === bar || bar.contains(target))) return true;
77
+ return options.ignore?.(target) ?? false;
78
+ }
79
+
80
+ function ensureOutline(): HTMLDivElement {
81
+ if (outline?.isConnected) return outline;
82
+ outline?.remove();
83
+ outline = null;
84
+ const el = doc.createElement("div");
85
+ el.setAttribute("data-backmatter-pointer", "");
86
+ el.style.position = "fixed";
87
+ el.style.pointerEvents = "none";
88
+ el.style.zIndex = "2147482980";
89
+ el.style.boxSizing = "border-box";
90
+ el.style.border = "1.5px solid #18181b";
91
+ el.style.borderRadius = "4px";
92
+ el.style.background = "rgba(24, 24, 27, 0.06)";
93
+ const label = doc.createElement("div");
94
+ label.setAttribute("data-backmatter-pointer-label", "");
95
+ label.style.position = "absolute";
96
+ label.style.left = "0";
97
+ label.style.top = "0";
98
+ label.style.maxWidth = "240px";
99
+ label.style.overflow = "hidden";
100
+ label.style.textOverflow = "ellipsis";
101
+ label.style.whiteSpace = "nowrap";
102
+ label.style.padding = "2px 6px";
103
+ label.style.borderRadius = "4px";
104
+ label.style.background = "#18181b";
105
+ label.style.color = "#ffffff";
106
+ label.style.font = '11px/1.4 ui-sans-serif, system-ui, sans-serif';
107
+ label.style.display = "none";
108
+ el.appendChild(label);
109
+ doc.body.appendChild(el);
110
+ outline = el;
111
+ return el;
112
+ }
113
+
114
+ function place(target: Element | null) {
115
+ current = target;
116
+ if (!target) {
117
+ if (outline) outline.style.display = "none";
118
+ return;
119
+ }
120
+ const el = ensureOutline();
121
+ const rect = target.getBoundingClientRect();
122
+ if (rect.width < 1 && rect.height < 1) {
123
+ el.style.display = "none";
124
+ return;
125
+ }
126
+ el.style.display = "block";
127
+ el.style.left = `${rect.left}px`;
128
+ el.style.top = `${rect.top}px`;
129
+ el.style.width = `${rect.width}px`;
130
+ el.style.height = `${rect.height}px`;
131
+ const label = el.firstElementChild as HTMLElement | null;
132
+ const name = target.closest("[data-backmatter-block]")?.getAttribute("data-backmatter-type") ?? "";
133
+ if (!label) return;
134
+ if (!name) {
135
+ label.textContent = "";
136
+ label.style.display = "none";
137
+ return;
138
+ }
139
+ label.textContent = name;
140
+ label.style.display = "block";
141
+ label.style.transform = rect.top < 28 ? "none" : "translateY(calc(-100% - 4px))";
142
+ }
143
+
144
+ function onMove(event: PointerEvent) {
145
+ const target = event.target instanceof Element ? event.target : null;
146
+ place(target && !ignored(target) ? target : null);
147
+ }
148
+
149
+ function onScroll() {
150
+ if (current && !ignored(current)) place(current);
151
+ else place(null);
152
+ }
153
+
154
+ function onClick(event: MouseEvent) {
155
+ if (!armed || ignored(event.target)) return;
156
+ event.preventDefault();
157
+ event.stopPropagation();
158
+ const target = event.target instanceof Element ? event.target : null;
159
+ const grab = grabAt(target, options.bar());
160
+ if (grab) options.onGrab(grab);
161
+ }
162
+
163
+ function onKey(event: KeyboardEvent) {
164
+ if (!armed || event.key !== "Escape") return;
165
+ event.preventDefault();
166
+ event.stopPropagation();
167
+ disarm();
168
+ options.onEscape?.();
169
+ }
170
+
171
+ function arm() {
172
+ if (armed) return;
173
+ armed = true;
174
+ root.classList.add("backmatter-pointing");
175
+ doc.addEventListener("pointermove", onMove, true);
176
+ doc.addEventListener("scroll", onScroll, true);
177
+ doc.addEventListener("click", onClick, true);
178
+ doc.addEventListener("keydown", onKey, true);
179
+ }
180
+
181
+ function disarm() {
182
+ if (!armed) return;
183
+ armed = false;
184
+ current = null;
185
+ root.classList.remove("backmatter-pointing");
186
+ doc.removeEventListener("pointermove", onMove, true);
187
+ doc.removeEventListener("scroll", onScroll, true);
188
+ doc.removeEventListener("click", onClick, true);
189
+ doc.removeEventListener("keydown", onKey, true);
190
+ outline?.remove();
191
+ outline = null;
192
+ }
193
+
194
+ return {
195
+ arm,
196
+ disarm,
197
+ dispose: disarm,
198
+ };
199
+ }
@@ -24,6 +24,32 @@ export type StudioMountProps = {
24
24
 
25
25
  type SiteStudioComponent = ComponentType<StudioMountProps>;
26
26
 
27
+ const fallbackStyle = {
28
+ minHeight: "100dvh",
29
+ display: "grid",
30
+ placeItems: "center",
31
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
32
+ color: "#71717a",
33
+ } as const;
34
+
35
+ const SESSION_EXPIRED_MESSAGE = "backmatter:session-expired";
36
+
37
+ /**
38
+ * Rendered instead of redirecting to the dashboard. A frame tells the parent
39
+ * to drop the bar. The top window goes to `enterUrl` to sign in.
40
+ */
41
+ export function StudioSignedOut({ enterUrl }: { enterUrl: string }) {
42
+ useEffect(() => {
43
+ const framed = Boolean(window.top && window.top !== window);
44
+ if (framed) {
45
+ window.parent.postMessage({ type: SESSION_EXPIRED_MESSAGE }, window.location.origin);
46
+ return;
47
+ }
48
+ window.location.assign(enterUrl);
49
+ }, [enterUrl]);
50
+ return <div style={fallbackStyle} aria-busy="true" />;
51
+ }
52
+
27
53
  export function StudioMount(props: StudioMountProps) {
28
54
  const [Studio, setStudio] = useState<SiteStudioComponent | null>(null);
29
55
  const [error, setError] = useState<string | null>(null);
@@ -52,11 +78,3 @@ export function StudioMount(props: StudioMountProps) {
52
78
  if (!Studio) return <div style={fallbackStyle} aria-busy="true" />;
53
79
  return <Studio {...props} />;
54
80
  }
55
-
56
- const fallbackStyle = {
57
- minHeight: "100dvh",
58
- display: "grid",
59
- placeItems: "center",
60
- fontFamily: "ui-sans-serif, system-ui, sans-serif",
61
- color: "#71717a",
62
- } as const;
package/src/studio.tsx CHANGED
@@ -10,8 +10,8 @@
10
10
  // export default createStudioPage(cms);
11
11
  //
12
12
  // The Studio talks to `/api/backmatter/cms/*` on this origin with the site
13
- // session cookie. Without a session the page redirects top-level to the
14
- // dashboard `enter` route, which mints a ticket and returns here.
13
+ // session cookie. Without a session, a frame tells the parent page and a
14
+ // top-level visit goes to the dashboard `enter` route.
15
15
  import { Suspense, type ReactNode } from "react";
16
16
  import {
17
17
  PREVIEW_SESSION_COOKIE,
@@ -23,7 +23,7 @@ import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
23
23
  import studioPkg from "@backmatter/studio/package.json";
24
24
  import type { StudioDefinition } from "@backmatter/studio/define";
25
25
  import { requestUrlFrom } from "./request-url";
26
- import { StudioMount } from "./studio-client";
26
+ import { StudioMount, StudioSignedOut } from "./studio-client";
27
27
 
28
28
  export { requestUrlFrom };
29
29
 
@@ -50,21 +50,22 @@ type NextHeaders = {
50
50
  headers: () => Promise<{ get: (name: string) => string | null }>;
51
51
  };
52
52
 
53
- type NextNavigation = { redirect: (url: string) => never };
54
-
55
53
  async function nextHeaders(): Promise<NextHeaders> {
56
54
  return (await import("next/headers" as string)) as NextHeaders;
57
55
  }
58
56
 
59
- async function nextNavigation(): Promise<NextNavigation> {
60
- return (await import("next/navigation" as string)) as NextNavigation;
61
- }
62
-
63
57
  export function studioPathFor(basePath: string, segments: string[] | undefined, search = ""): string {
64
58
  const rest = segments && segments.length > 0 ? `/${segments.map(encodeURIComponent).join("/")}` : "";
65
59
  return `${basePath}${rest}${search}`;
66
60
  }
67
61
 
62
+ type StudioPageProps = {
63
+ params: Promise<{ path?: string[] }> | { path?: string[] };
64
+ searchParams?:
65
+ | Promise<Record<string, string | string[] | undefined>>
66
+ | Record<string, string | string[] | undefined>;
67
+ };
68
+
68
69
  export function createStudioPage(
69
70
  cms: Pick<BackmatterClient, "config">,
70
71
  options: StudioPageOptions = {},
@@ -73,14 +74,18 @@ export function createStudioPage(
73
74
  const proxyPath = options.proxyPath ?? "/api/backmatter/cms";
74
75
  const previewEntryPath = options.previewEntryPath ?? "/api/backmatter/preview";
75
76
 
76
- return async function StudioPage(props: {
77
- params: Promise<{ path?: string[] }> | { path?: string[] };
78
- searchParams?: Promise<Record<string, string | string[] | undefined>> | Record<string, string | string[] | undefined>;
79
- }) {
77
+ function requireWorkspaceId(): string {
80
78
  const workspaceId = options.workspaceId ?? cms.config.workspaceId;
81
79
  if (!workspaceId) {
82
80
  throw new Error("backmatter: the Studio needs a workspaceId (backmatter.config.ts or BACKMATTER_WORKSPACE_ID)");
83
81
  }
82
+ return workspaceId;
83
+ }
84
+
85
+ // Cookie and header reads stay in this child. The page returns Suspense
86
+ // first so Cache Components can prerender the route shell.
87
+ async function StudioSession(props: StudioPageProps & { workspaceId: string }) {
88
+ const { workspaceId } = props;
84
89
  const dashboardUrl = resolveDashboardUrl(options.dashboardUrl);
85
90
  const { cookies, headers } = await nextHeaders();
86
91
  const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
@@ -102,8 +107,7 @@ export function createStudioPage(
102
107
  (name) => headerStore.get(name),
103
108
  studioPathFor(basePath, params.path, searchText),
104
109
  );
105
- const { redirect } = await nextNavigation();
106
- redirect(studioEnterUrl(dashboardUrl, { workspaceId, returnUrl }));
110
+ return <StudioSignedOut enterUrl={studioEnterUrl(dashboardUrl, { workspaceId, returnUrl })} />;
107
111
  }
108
112
 
109
113
  return (
@@ -118,6 +122,15 @@ export function createStudioPage(
118
122
  studio={options.studio}
119
123
  />
120
124
  );
125
+ }
126
+
127
+ return function StudioPage(props: StudioPageProps) {
128
+ const workspaceId = requireWorkspaceId();
129
+ return (
130
+ <Suspense fallback={null}>
131
+ <StudioSession {...props} workspaceId={workspaceId} />
132
+ </Suspense>
133
+ );
121
134
  };
122
135
  }
123
136
 
@@ -133,7 +146,7 @@ export function StudioLayout({ children }: { children: ReactNode }) {
133
146
  <script dangerouslySetInnerHTML={{ __html: THEME_BOOT }} />
134
147
  </head>
135
148
  <body style={{ margin: 0 }}>
136
- {/* The page reads cookies; with Cache Components that must sit under Suspense. */}
149
+ {/* The page suspends on the session. This boundary keeps the document shell outside that hole. */}
137
150
  <Suspense fallback={null}>{children}</Suspense>
138
151
  </body>
139
152
  </html>