@backmatter/next 0.0.22 → 0.0.23

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.22",
3
+ "version": "0.0.23",
4
4
  "description": "Experimental Next.js adapter for Backmatter. APIs will break. Not ready for production.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -33,7 +33,8 @@
33
33
  "./cms-proxy": "./src/cms-proxy.ts",
34
34
  "./proxy": "./src/proxy.ts",
35
35
  "./middleware": "./src/middleware.ts",
36
- "./page": "./src/page-render.ts"
36
+ "./page": "./src/page-render.ts",
37
+ "./studio": "./src/studio.tsx"
37
38
  },
38
39
  "files": [
39
40
  "src",
@@ -46,9 +47,10 @@
46
47
  "test": "bun test"
47
48
  },
48
49
  "dependencies": {
49
- "@backmatter/core": "^0.0.19",
50
+ "@backmatter/core": "^0.0.20",
50
51
  "@mdx-js/mdx": "^3.1.1",
51
- "remark-gfm": "^4.0.1"
52
+ "remark-gfm": "^4.0.1",
53
+ "@backmatter/studio": "^0.0.1"
52
54
  },
53
55
  "peerDependencies": {
54
56
  "next": ">=16.3.0",
@@ -56,7 +58,9 @@
56
58
  "react-dom": ">=19"
57
59
  },
58
60
  "peerDependenciesMeta": {
59
- "next": { "optional": true }
61
+ "next": {
62
+ "optional": true
63
+ }
60
64
  },
61
65
  "devDependencies": {
62
66
  "@types/bun": "^1.3.14",
package/src/cms-proxy.ts CHANGED
@@ -6,21 +6,41 @@ import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
6
6
 
7
7
  const DOC = "/workspaces/[^/]+/docs/[^/]+/[^/]+/[^/]+";
8
8
 
9
+ const WS = "/workspaces/[^/]+";
10
+
11
+ /**
12
+ * Everything the Studio does from a customer site. Admin surfaces that change
13
+ * the workspace itself (members, settings, GitHub binding, tokens) are absent
14
+ * on purpose: they stay on the dashboard.
15
+ */
9
16
  const ALLOWED: Array<{ method: string; pattern: RegExp }> = [
10
- { method: "GET", pattern: /^\/workspaces\/[^/]+\/docs$/ },
17
+ { method: "GET", pattern: /^\/me$/ },
18
+ { method: "GET", pattern: /^\/workspaces$/ },
19
+ { method: "GET", pattern: new RegExp(`^${WS}$`) },
20
+ { method: "GET", pattern: new RegExp(`^${WS}/replica$`) },
21
+ { method: "GET", pattern: new RegExp(`^${WS}/schema$`) },
22
+ { method: "GET", pattern: new RegExp(`^${WS}/docs$`) },
11
23
  { method: "GET", pattern: new RegExp(`^${DOC}$`) },
12
24
  { method: "PUT", pattern: new RegExp(`^${DOC}$`) },
13
25
  { method: "DELETE", pattern: new RegExp(`^${DOC}$`) },
14
26
  { method: "DELETE", pattern: new RegExp(`^${DOC}/tree$`) },
15
27
  { method: "POST", pattern: new RegExp(`^${DOC}/resolve$`) },
16
- { method: "GET", pattern: /^\/workspaces\/[^/]+\/git$/ },
17
- { method: "POST", pattern: /^\/workspaces\/[^/]+\/git\/commit$/ },
18
- { method: "POST", pattern: /^\/workspaces\/[^/]+\/preview-ticket$/ },
28
+ { method: "GET", pattern: new RegExp(`^${WS}/git$`) },
29
+ { method: "POST", pattern: new RegExp(`^${WS}/git/commit$`) },
30
+ { method: "POST", pattern: new RegExp(`^${WS}/git/checkout$`) },
31
+ { method: "POST", pattern: new RegExp(`^${WS}/git/branches$`) },
32
+ { method: "POST", pattern: new RegExp(`^${WS}/preview-ticket$`) },
19
33
  { method: "GET", pattern: /^\/preview\/workspaces\/[^/]+\/docs\/[^/]+\/[^/]+\/[^/]+\/stream$/ },
20
34
  { method: "GET", pattern: /^\/preview\/workspaces\/[^/]+\/tree\/stream$/ },
21
- { method: "GET", pattern: /^\/workspaces\/[^/]+\/ai\/threads\/[^/]+$/ },
22
- { method: "POST", pattern: /^\/workspaces\/[^/]+\/ai\/threads$/ },
23
- { method: "POST", pattern: /^\/workspaces\/[^/]+\/ai\/threads\/[^/]+\/messages$/ },
35
+ { method: "GET", pattern: new RegExp(`^${WS}/ai/models$`) },
36
+ { method: "GET", pattern: new RegExp(`^${WS}/ai/threads$`) },
37
+ { method: "POST", pattern: new RegExp(`^${WS}/ai/threads$`) },
38
+ { method: "GET", pattern: new RegExp(`^${WS}/ai/threads/[^/]+$`) },
39
+ { method: "PATCH", pattern: new RegExp(`^${WS}/ai/threads/[^/]+$`) },
40
+ { method: "DELETE", pattern: new RegExp(`^${WS}/ai/threads/[^/]+$`) },
41
+ { method: "POST", pattern: new RegExp(`^${WS}/ai/threads/[^/]+/messages$`) },
42
+ { method: "POST", pattern: new RegExp(`^${WS}/ai/threads/[^/]+/messages/[^/]+/changes/(keep-all|undo-all)$`) },
43
+ { method: "POST", pattern: new RegExp(`^${WS}/ai/changes/[^/]+/(keep|undo)$`) },
24
44
  { method: "GET", pattern: /^\/preview\/session$/ },
25
45
  { method: "POST", pattern: /^\/preview\/session\/renew$/ },
26
46
  ];
@@ -35,6 +55,40 @@ export function workspaceIdFromProxyPath(path: string): string | null {
35
55
  return match?.[1] ?? null;
36
56
  }
37
57
 
58
+ const WORKSPACE_ID = /^ws_/;
59
+
60
+ /**
61
+ * The Studio addresses a workspace by slug, as the dashboard does. The proxy
62
+ * cannot verify a slug, so a slug segment is rewritten to the session's
63
+ * workspace id — the session is scoped to exactly one workspace anyway, and
64
+ * the API still checks the token against the workspace it resolves. A
65
+ * different workspace *id* is refused outright.
66
+ */
67
+ export function resolveProxyPath(
68
+ path: string,
69
+ sessionWorkspaceId: string,
70
+ ): { ok: true; path: string } | { ok: false } {
71
+ const segment = workspaceIdFromProxyPath(path);
72
+ if (!segment || segment === sessionWorkspaceId) return { ok: true, path };
73
+ if (WORKSPACE_ID.test(segment)) return { ok: false };
74
+ return {
75
+ ok: true,
76
+ path: path.replace(
77
+ /^(\/(?:preview\/)?workspaces\/)[^/]+/,
78
+ (_m, prefix: string) => `${prefix}${sessionWorkspaceId}`,
79
+ ),
80
+ };
81
+ }
82
+
83
+ /** Public origin of the site, behind a proxy when forwarded headers say so. */
84
+ export function siteOrigin(req: Request, url: URL): string {
85
+ const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? url.host;
86
+ const proto =
87
+ req.headers.get("x-forwarded-proto") ??
88
+ (host.startsWith("localhost") || host.startsWith("127.0.0.1") ? "http" : url.protocol.replace(":", ""));
89
+ return `${proto}://${host}`;
90
+ }
91
+
38
92
  function cookieValue(header: string | null, name: string): string | undefined {
39
93
  if (!header) return undefined;
40
94
  for (const part of header.split(";")) {
@@ -74,15 +128,15 @@ export function createCmsProxy(
74
128
  const token = await readSessionToken(req);
75
129
  const session = await readPreviewSession(secret, token);
76
130
  if (!session) return Response.json({ error: "unauthorized" }, { status: 401 });
77
- const pathWorkspace = workspaceIdFromProxyPath(path);
78
- if (pathWorkspace && pathWorkspace !== session.workspaceId) {
131
+ const resolved = resolveProxyPath(path, session.workspaceId);
132
+ if (!resolved.ok) {
79
133
  return Response.json({ error: "forbidden" }, { status: 403 });
80
134
  }
81
135
  if (cms.config.workspaceId && session.workspaceId !== cms.config.workspaceId) {
82
136
  return Response.json({ error: "forbidden" }, { status: 403 });
83
137
  }
84
138
 
85
- const upstream = new URL(`${apiUrl.replace(/\/$/, "")}${path}`);
139
+ const upstream = new URL(`${apiUrl.replace(/\/$/, "")}${resolved.path}`);
86
140
  upstream.search = url.search;
87
141
  const headers = new Headers();
88
142
  headers.set("Authorization", `Bearer ${token}`);
@@ -90,6 +144,10 @@ export function createCmsProxy(
90
144
  if (contentType) headers.set("Content-Type", contentType);
91
145
  const accept = req.headers.get("accept");
92
146
  if (accept) headers.set("Accept", accept);
147
+ const studio = req.headers.get("x-backmatter-studio");
148
+ if (studio) headers.set("X-Backmatter-Studio", studio);
149
+ // Which site this Studio runs on, so the dashboard can list versions per site.
150
+ headers.set("X-Backmatter-Site", siteOrigin(req, url));
93
151
 
94
152
  const hasBody = req.method !== "GET" && req.method !== "HEAD";
95
153
  const res = await forward(upstream, {
@@ -104,5 +162,5 @@ export function createCmsProxy(
104
162
  return new Response(res.body, { status: res.status, headers: responseHeaders });
105
163
  };
106
164
 
107
- return { GET: handle, POST: handle, PUT: handle, DELETE: handle };
165
+ return { GET: handle, POST: handle, PUT: handle, DELETE: handle, PATCH: handle };
108
166
  }
package/src/css.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ // packages/next/src/css.d.ts
2
+ declare module "*.css";
@@ -1,8 +1,7 @@
1
1
  // packages/next/src/devbar-client.tsx
2
2
  "use client";
3
3
 
4
- import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
5
- import { SiteChrome } from "./site-editor";
4
+ import { Suspense, lazy, useEffect, useState, type CSSProperties, type ReactNode } from "react";
6
5
  import {
7
6
  clearHostPreviewSession,
8
7
  fetchCmsSession,
@@ -10,6 +9,18 @@ import {
10
9
  resolveDevMode,
11
10
  type CmsSessionUser,
12
11
  } from "@backmatter/core/preview";
12
+ import {
13
+ ENTER_ATTEMPT_KEY,
14
+ enterPath,
15
+ gateOpen,
16
+ isGateShortcut,
17
+ markerCookie,
18
+ recentEnterAttempt,
19
+ stripOptInParam,
20
+ } from "./gate";
21
+
22
+ /** The bar and its panels load only after the gate opens. */
23
+ const SiteChrome = lazy(() => import("./site-editor").then((m) => ({ default: m.SiteChrome })));
13
24
 
14
25
  export type { CmsSessionUser };
15
26
 
@@ -276,8 +287,90 @@ export function DevBarView({
276
287
  );
277
288
  }
278
289
 
279
- export function DevBarClient({ apiUrl }: { apiUrl: string }) {
280
- const state = useDevMode({ apiUrl });
290
+ const DEFAULT_PREVIEW_ENTRY_PATH = "/api/backmatter/preview";
291
+
292
+ type Gate = "closed" | "open";
293
+
294
+ /**
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.
298
+ */
299
+ function useGate(): Gate {
300
+ const [gate, setGate] = useState<Gate>("closed");
301
+
302
+ useEffect(() => {
303
+ // Framed by an editor (the Studio's split preview, the dashboard's split
304
+ // view): that editor is the chrome, so the page shows no bar of its own.
305
+ if (window.self !== window.top) return;
306
+ const secure = location.protocol === "https:";
307
+ if (gateOpen({ search: location.search, cookie: document.cookie })) {
308
+ document.cookie = markerCookie(true, secure);
309
+ const clean = stripOptInParam(location.href);
310
+ if (clean) history.replaceState(history.state, "", clean);
311
+ setGate("open");
312
+ return;
313
+ }
314
+ const onKey = (e: KeyboardEvent) => {
315
+ if (!isGateShortcut(e)) return;
316
+ e.preventDefault();
317
+ document.cookie = markerCookie(true, secure);
318
+ setGate("open");
319
+ };
320
+ window.addEventListener("keydown", onKey);
321
+ return () => window.removeEventListener("keydown", onKey);
322
+ }, []);
323
+
324
+ return gate;
325
+ }
326
+
327
+ /**
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.
332
+ */
333
+ function useSiteSession(gate: Gate, previewEntryPath: string): DevModeState {
334
+ const [state, setState] = useState<DevModeState>({ status: "hidden" });
335
+
336
+ useEffect(() => {
337
+ if (gate !== "open") return;
338
+ let cancelled = false;
339
+ void loadDevModeState("").then((next) => {
340
+ if (cancelled) return;
341
+ if (next.status === "ready" && next.mode === "preview") {
342
+ sessionStorage.removeItem(ENTER_ATTEMPT_KEY);
343
+ setState(next);
344
+ return;
345
+ }
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" });
350
+ return;
351
+ }
352
+ sessionStorage.setItem(ENTER_ATTEMPT_KEY, String(Date.now()));
353
+ location.assign(enterPath(previewEntryPath, location.pathname + location.search + location.hash));
354
+ });
355
+ return () => {
356
+ cancelled = true;
357
+ };
358
+ }, [gate, previewEntryPath]);
359
+
360
+ return state;
361
+ }
362
+
363
+ export function DevBarClient({
364
+ studioPath,
365
+ previewEntryPath = DEFAULT_PREVIEW_ENTRY_PATH,
366
+ }: {
367
+ /** Unused since the bar stopped calling the CMS origin; kept for host layouts that still pass it. */
368
+ apiUrl?: string;
369
+ studioPath?: string;
370
+ previewEntryPath?: string;
371
+ }) {
372
+ const gate = useGate();
373
+ const state = useSiteSession(gate, previewEntryPath);
281
374
  const [exiting, setExiting] = useState(false);
282
375
  const exit = () => {
283
376
  if (exiting) return;
@@ -286,8 +379,19 @@ export function DevBarClient({ apiUrl }: { apiUrl: string }) {
286
379
  if (!ok) setExiting(false);
287
380
  });
288
381
  };
289
- if (state.status === "ready" && state.mode === "preview") {
290
- return <SiteChrome branch={state.branch} tree={state.tree} exiting={exiting} onExit={exit} />;
382
+ if (state.status !== "ready") return null;
383
+ if (state.mode === "preview") {
384
+ return (
385
+ <Suspense fallback={null}>
386
+ <SiteChrome
387
+ branch={state.branch}
388
+ tree={state.tree}
389
+ exiting={exiting}
390
+ onExit={exit}
391
+ studioPath={studioPath}
392
+ />
393
+ </Suspense>
394
+ );
291
395
  }
292
396
  return <DevBarView state={state} exiting={exiting} onExit={exit} />;
293
397
  }
package/src/devbar.tsx CHANGED
@@ -13,7 +13,24 @@ export {
13
13
  type DevModeState,
14
14
  } from "./devbar-client";
15
15
 
16
- /** Host layout: `<DevBar />`. Optional apiUrl for local/self-hosted CMS. */
17
- export function DevBar({ apiUrl }: { apiUrl?: string } = {}) {
18
- return <DevBarClient apiUrl={resolveDevBarApiUrl(apiUrl)} />;
16
+ /**
17
+ * 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.
21
+ * `previewEntryPath` is the ticket route (default `/api/backmatter/preview`).
22
+ * `apiUrl` is accepted for older layouts and no longer used.
23
+ */
24
+ export function DevBar({
25
+ apiUrl,
26
+ studioPath,
27
+ previewEntryPath,
28
+ }: { apiUrl?: string; studioPath?: string; previewEntryPath?: string } = {}) {
29
+ return (
30
+ <DevBarClient
31
+ apiUrl={resolveDevBarApiUrl(apiUrl)}
32
+ studioPath={studioPath}
33
+ previewEntryPath={previewEntryPath}
34
+ />
35
+ );
19
36
  }
package/src/gate.ts ADDED
@@ -0,0 +1,85 @@
1
+ // packages/next/src/gate.ts
2
+ // The visitor gate in front of the editing bar. Pure helpers so the decision
3
+ // is testable without a DOM: a page opens the bar only when the visitor opted
4
+ // in through `?backmatter=1`, carries the marker cookie `bm_ui=1`, or presses
5
+ // the desktop shortcut. Anyone else gets no fetch and no editor code.
6
+
7
+ export const MARKER_COOKIE = "bm_ui";
8
+ export const OPT_IN_PARAM = "backmatter";
9
+ /** 30 days, per origin. Not HttpOnly: the gate reads it before any request. */
10
+ export const MARKER_TTL_S = 30 * 24 * 60 * 60;
11
+ /** Written when the gate bounces to the dashboard; read on return to stop a loop. */
12
+ export const ENTER_ATTEMPT_KEY = "backmatter:enter";
13
+ export const ENTER_ATTEMPT_WINDOW_MS = 60_000;
14
+
15
+ export function hasMarker(cookieHeader: string | null | undefined): boolean {
16
+ if (!cookieHeader) return false;
17
+ for (const part of cookieHeader.split(";")) {
18
+ const [key, ...rest] = part.trim().split("=");
19
+ if (key === MARKER_COOKIE) return rest.join("=") === "1";
20
+ }
21
+ return false;
22
+ }
23
+
24
+ export function hasOptInParam(search: string): boolean {
25
+ return new URLSearchParams(search).get(OPT_IN_PARAM) === "1";
26
+ }
27
+
28
+ export function gateOpen(input: { search: string; cookie: string | null | undefined }): boolean {
29
+ return hasOptInParam(input.search) || hasMarker(input.cookie);
30
+ }
31
+
32
+ /** `document.cookie` assignment that sets or clears the marker. */
33
+ export function markerCookie(on: boolean, secure = false): string {
34
+ const parts = [
35
+ `${MARKER_COOKIE}=${on ? "1" : ""}`,
36
+ "Path=/",
37
+ `Max-Age=${on ? MARKER_TTL_S : 0}`,
38
+ "SameSite=Lax",
39
+ ];
40
+ if (secure) parts.push("Secure");
41
+ return parts.join("; ");
42
+ }
43
+
44
+ /** The same href without `?backmatter=1`, or null when the param was absent. */
45
+ export function stripOptInParam(href: string): string | null {
46
+ const url = new URL(href);
47
+ if (url.searchParams.get(OPT_IN_PARAM) !== "1") return null;
48
+ url.searchParams.delete(OPT_IN_PARAM);
49
+ return url.toString();
50
+ }
51
+
52
+ /** ⌘⇧E on macOS, Ctrl+Shift+E elsewhere. Not bound by the major browsers. */
53
+ export function isGateShortcut(e: {
54
+ key: string;
55
+ metaKey: boolean;
56
+ ctrlKey: boolean;
57
+ shiftKey: boolean;
58
+ altKey: boolean;
59
+ }): boolean {
60
+ if (!e.shiftKey || e.altKey) return false;
61
+ if (!(e.metaKey || e.ctrlKey)) return false;
62
+ return e.key === "E" || e.key === "e";
63
+ }
64
+
65
+ /** Same-origin path only: must start with one `/`. Anything else goes home. */
66
+ export function safeReturnPath(raw: string | null | undefined): string {
67
+ const value = raw?.trim() ?? "";
68
+ if (!value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\")) return "/";
69
+ return value;
70
+ }
71
+
72
+ /** The host route that bounces to the dashboard `enter` and comes back to `returnPath`. */
73
+ export function enterPath(previewEntryPath: string, returnPath: string): string {
74
+ return `${previewEntryPath}?enter=1&return=${encodeURIComponent(safeReturnPath(returnPath))}`;
75
+ }
76
+
77
+ /**
78
+ * Loop guard: true when a bounce started within the window, meaning we came
79
+ * back from the dashboard without a session. The caller then gives up and
80
+ * clears the marker instead of bouncing again.
81
+ */
82
+ export function recentEnterAttempt(stored: string | null, now: number): boolean {
83
+ const at = Number(stored);
84
+ return Number.isFinite(at) && at > 0 && now - at < ENTER_ATTEMPT_WINDOW_MS;
85
+ }
@@ -39,17 +39,6 @@ function referrerOrigin(): string {
39
39
  }
40
40
  }
41
41
 
42
- function dashboardOriginFromMeta(): string {
43
- const marked = document
44
- .querySelector('meta[name="backmatter:dashboard"]')
45
- ?.getAttribute("content");
46
- if (marked) return marked.replace(/\/$/, "");
47
- if (location.hostname === "localhost" || location.hostname === "127.0.0.1") {
48
- return `${location.protocol}//${location.hostname}:5173`;
49
- }
50
- return "";
51
- }
52
-
53
42
  function subscribeDocStream(
54
43
  ref: { locale: string; type: string; slug: string },
55
44
  onEvent: (event: { raw?: string; body?: string; frontmatter?: Record<string, unknown> }) => void,
@@ -169,13 +158,15 @@ export function PreviewClient({
169
158
  ...(branch ? { branch } : {}),
170
159
  ...(tree ? { tree } : {}),
171
160
  };
161
+ // Framed by the dashboard's split view: the parent is the dashboard.
162
+ // Otherwise the Studio mounted on this site posts from same-origin frames.
172
163
  const parentOrigin = referrerOrigin();
173
- const dashboardOrigin = dashboardOriginFromMeta();
174
- const allowed = [...originsKey.split("\0"), parentOrigin, dashboardOrigin].filter(
164
+ const selfOrigin = window.location.origin;
165
+ const allowed = [...originsKey.split("\0"), parentOrigin, selfOrigin].filter(
175
166
  (origin) => origin.length > 0,
176
167
  );
177
168
  const framed = window.parent !== window;
178
- const targetOrigin = framed ? parentOrigin || allowed[0] || "" : dashboardOrigin;
169
+ const targetOrigin = framed ? parentOrigin || allowed[0] || "" : selfOrigin;
179
170
  const windowTransport: PreviewTransport | null = targetOrigin
180
171
  ? createWindowPreviewTransport({
181
172
  target: framed ? window.parent : { postMessage() {} },
@@ -8,19 +8,37 @@ import {
8
8
  previewLocationWithCheckout,
9
9
  readPreviewCookie,
10
10
  readPreviewTicket,
11
+ resolveDashboardUrl,
12
+ studioEnterUrl,
11
13
  writePreviewCookie,
12
14
  type PreviewCheckout,
13
15
  } from "@backmatter/core";
14
16
  import { previewStreamUrl, previewTreeStreamUrl } from "@backmatter/core/preview";
15
17
  import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
18
+ import { MARKER_COOKIE, MARKER_TTL_S, safeReturnPath } from "./gate";
19
+ import { requestUrlFrom } from "./request-url";
16
20
 
17
21
  export type PreviewRouteDeps = {
18
22
  enableDraft?: () => void | Promise<void>;
19
23
  disableDraft?: () => void | Promise<void>;
20
24
  isDraftEnabled?: () => boolean | Promise<boolean>;
21
25
  now?: () => number;
26
+ /** Dashboard that holds the login, for `?enter=1`. Default: `resolveDashboardUrl()`. */
27
+ dashboardUrl?: string;
22
28
  };
23
29
 
30
+ /** The gate's marker: readable by the page script, so never HttpOnly. */
31
+ function markerCookieHeader(secure: boolean, on: boolean): string {
32
+ const parts = [
33
+ `${MARKER_COOKIE}=${on ? "1" : ""}`,
34
+ "Path=/",
35
+ `Max-Age=${on ? MARKER_TTL_S : 0}`,
36
+ "SameSite=Lax",
37
+ ];
38
+ if (secure) parts.push("Secure");
39
+ return parts.join("; ");
40
+ }
41
+
24
42
  function cookieBase(name: string, secure: boolean, maxAge: number, value: string) {
25
43
  // HTTPS is cross-site from the CMS iframe (dash → customer origin). Lax is
26
44
  // not sent there, so draftMode can be on while the checkout cookie is missing
@@ -95,6 +113,23 @@ export function createPreviewRoute(
95
113
  return Response.json({ mode: draftOn ? "preview" : "live" });
96
114
  }
97
115
 
116
+ // The bar found the marker but no session: go top-level to the dashboard,
117
+ // which mints a ticket after login and returns to this page.
118
+ if (url.searchParams.get("enter") === "1") {
119
+ if (!cms.config.workspaceId) {
120
+ return Response.json({ error: "preview_not_configured" }, { status: 503 });
121
+ }
122
+ const returnUrl = requestUrlFrom(
123
+ (name) => req.headers.get(name),
124
+ safeReturnPath(url.searchParams.get("return")),
125
+ );
126
+ const dashboardUrl = resolveDashboardUrl(deps.dashboardUrl);
127
+ return Response.redirect(
128
+ studioEnterUrl(dashboardUrl, { workspaceId: cms.config.workspaceId, returnUrl }),
129
+ 302,
130
+ );
131
+ }
132
+
98
133
  if (url.searchParams.get("stream") === "1") {
99
134
  if (!secret || !cms.config.apiUrl || !cms.config.workspaceId) {
100
135
  return new Response("preview not configured", { status: 503 });
@@ -169,6 +204,8 @@ export function createPreviewRoute(
169
204
  ),
170
205
  );
171
206
  }
207
+ // Entry opts the visitor in, so the bar shows on later pages without a param.
208
+ headers.append("Set-Cookie", markerCookieHeader(secure, true));
172
209
  return new Response(null, { status: 302, headers });
173
210
  };
174
211
 
@@ -178,6 +215,8 @@ export function createPreviewRoute(
178
215
  const headers = new Headers();
179
216
  headers.append("Set-Cookie", cookieBase(PREVIEW_COOKIE, secure, 0, ""));
180
217
  headers.append("Set-Cookie", cookieBase(PREVIEW_SESSION_COOKIE, secure, 0, ""));
218
+ // Exit also drops the marker; otherwise the next page would bounce straight back in.
219
+ headers.append("Set-Cookie", markerCookieHeader(secure, false));
181
220
  return new Response(null, { status: 204, headers });
182
221
  };
183
222
 
@@ -0,0 +1,13 @@
1
+ // packages/next/src/request-url.ts
2
+
3
+ /** Absolute URL of the current request, from forwarded headers when behind a proxy. */
4
+ export function requestUrlFrom(
5
+ get: (name: string) => string | null,
6
+ pathWithSearch: string,
7
+ ): string {
8
+ const host = get("x-forwarded-host") ?? get("host") ?? "localhost:3000";
9
+ const proto =
10
+ get("x-forwarded-proto") ??
11
+ (host.startsWith("localhost") || host.startsWith("127.0.0.1") ? "http" : "https");
12
+ return `${proto}://${host}${pathWithSearch}`;
13
+ }
@@ -32,14 +32,17 @@ function useSiteDoc(): SiteDocRef | null {
32
32
  return doc;
33
33
  }
34
34
 
35
- function dashboardOrigin(): string {
36
- if (typeof document === "undefined") return "";
37
- const marked = document.querySelector('meta[name="backmatter:dashboard"]')?.getAttribute("content");
38
- if (marked) return marked.replace(/\/$/, "");
39
- if (location.hostname === "localhost" || location.hostname === "127.0.0.1") {
40
- return `${location.protocol}//${location.hostname}:5173`;
41
- }
42
- return "";
35
+ /** Default route the host mounts `@backmatter/studio` on. */
36
+ export const DEFAULT_STUDIO_PATH = "/backmatter";
37
+
38
+ /**
39
+ * The panels are frames of the Studio mounted on this same origin. Same
40
+ * origin means the site session cookie is first-party and postMessage needs
41
+ * no foreign origin.
42
+ */
43
+ function studioBase(studioPath: string): string {
44
+ if (typeof location === "undefined") return "";
45
+ return `${location.origin}${studioPath.replace(/\/+$/, "")}`;
43
46
  }
44
47
 
45
48
  export function SiteChrome({
@@ -47,11 +50,14 @@ export function SiteChrome({
47
50
  tree,
48
51
  exiting,
49
52
  onExit,
53
+ studioPath = DEFAULT_STUDIO_PATH,
50
54
  }: {
51
55
  branch?: string;
52
56
  tree?: string;
53
57
  exiting?: boolean;
54
58
  onExit: () => void;
59
+ /** Where `@backmatter/studio` is mounted on this site. */
60
+ studioPath?: string;
55
61
  }) {
56
62
  const router = useRouter();
57
63
  const doc = useSiteDoc();
@@ -61,12 +67,14 @@ export function SiteChrome({
61
67
  const [chatOpen, setChatOpen] = useState(false);
62
68
  const [publishOpen, setPublishOpen] = useState(false);
63
69
  const [changeCount, setChangeCount] = useState(0);
64
- const [origin, setOrigin] = useState("");
70
+ // Set in an effect so SSR and the first client render agree.
71
+ const [base, setBase] = useState("");
72
+ const origin = typeof location === "undefined" ? "" : location.origin;
65
73
  const editorFrame = useRef<HTMLIFrameElement>(null);
66
74
 
67
75
  useEffect(() => {
68
- setOrigin(dashboardOrigin());
69
- }, []);
76
+ setBase(studioBase(studioPath));
77
+ }, [studioPath]);
70
78
 
71
79
  useEffect(() => {
72
80
  function onKey(event: KeyboardEvent) {
@@ -113,8 +121,8 @@ export function SiteChrome({
113
121
 
114
122
  const label = doc ? `${doc.type}/${doc.slug}` : "No document";
115
123
  const editorSrc =
116
- doc && origin
117
- ? `${origin}/workspaces/${doc.workspaceId}/docs/${doc.locale}/${doc.type}/${doc.slug}?embed=1`
124
+ doc && base
125
+ ? `${base}/workspaces/${doc.workspaceId}/docs/${doc.locale}/${doc.type}/${doc.slug}?embed=1`
118
126
  : "";
119
127
 
120
128
  function onChatWrote() {
@@ -143,12 +151,13 @@ export function SiteChrome({
143
151
  hidden={!chatOpen}
144
152
  onWrote={onChatWrote}
145
153
  onClosed={() => setChatOpen(false)}
154
+ base={base}
146
155
  />
147
156
  ) : null}
148
- {doc && origin ? (
157
+ {doc && base ? (
149
158
  <iframe
150
159
  title="Publish"
151
- src={`${origin}/embed/publish/${doc.workspaceId}`}
160
+ src={`${base}/embed/publish/${doc.workspaceId}`}
152
161
  style={{
153
162
  ...publishStyle,
154
163
  visibility: publishOpen ? "visible" : "hidden",
@@ -201,11 +210,14 @@ function CmsChat({
201
210
  hidden,
202
211
  onWrote,
203
212
  onClosed,
213
+ base,
204
214
  }: {
205
215
  doc: SiteDocRef;
206
216
  hidden: boolean;
207
217
  onWrote: () => void;
208
218
  onClosed: () => void;
219
+ /** Studio origin plus base path. */
220
+ base: string;
209
221
  }) {
210
222
  const frame = useRef<HTMLIFrameElement>(null);
211
223
  const onWroteRef = useRef(onWrote);
@@ -214,7 +226,7 @@ function CmsChat({
214
226
  onWroteRef.current = onWrote;
215
227
  onClosedRef.current = onClosed;
216
228
  hiddenRef.current = hidden;
217
- const origin = dashboardOrigin();
229
+ const origin = base ? new URL(base).origin : "";
218
230
 
219
231
  function openChat() {
220
232
  const frameEl = frame.current;
@@ -246,12 +258,12 @@ function CmsChat({
246
258
  return () => window.removeEventListener("message", onMessage);
247
259
  }, [doc.locale, doc.type, doc.slug, origin]);
248
260
 
249
- if (!origin) return null;
261
+ if (!base) return null;
250
262
  return (
251
263
  <iframe
252
264
  ref={frame}
253
265
  title="AI chat"
254
- src={`${origin}/embed/chat/${doc.workspaceId}`}
266
+ src={`${base}/embed/chat/${doc.workspaceId}`}
255
267
  style={{
256
268
  ...chatStyle,
257
269
  visibility: hidden ? "hidden" : "visible",
@@ -0,0 +1,62 @@
1
+ "use client";
2
+ // packages/next/src/studio-client.tsx
3
+ // Loads @backmatter/studio in the browser only. The Studio is a client app
4
+ // (react-router, IndexedDB replica, StyleX output); it has no server render.
5
+ import { useEffect, useState, type ComponentType } from "react";
6
+ import type { StudioDefinition } from "@backmatter/studio/define";
7
+ import "@backmatter/studio/studio.css";
8
+
9
+ export type StudioMountProps = {
10
+ workspaceId: string;
11
+ basePath: string;
12
+ dashboardUrl: string;
13
+ version: string;
14
+ proxyPath: string;
15
+ previewEntryPath: string;
16
+ /** CMS API origin for display (Content API URLs); requests use the proxy. */
17
+ apiUrl?: string;
18
+ /**
19
+ * `defineStudio(...)` from a `"use client"` module. The server page passes
20
+ * the client reference; it resolves to the object here.
21
+ */
22
+ studio?: StudioDefinition;
23
+ };
24
+
25
+ type SiteStudioComponent = ComponentType<StudioMountProps>;
26
+
27
+ export function StudioMount(props: StudioMountProps) {
28
+ const [Studio, setStudio] = useState<SiteStudioComponent | null>(null);
29
+ const [error, setError] = useState<string | null>(null);
30
+
31
+ useEffect(() => {
32
+ let cancelled = false;
33
+ import("@backmatter/studio")
34
+ .then((mod) => {
35
+ if (!cancelled) setStudio(() => mod.SiteStudio as SiteStudioComponent);
36
+ })
37
+ .catch((err: unknown) => {
38
+ if (!cancelled) setError(err instanceof Error ? err.message : "Could not load the Studio");
39
+ });
40
+ return () => {
41
+ cancelled = true;
42
+ };
43
+ }, []);
44
+
45
+ if (error) {
46
+ return (
47
+ <div style={fallbackStyle} role="alert">
48
+ {error}
49
+ </div>
50
+ );
51
+ }
52
+ if (!Studio) return <div style={fallbackStyle} aria-busy="true" />;
53
+ return <Studio {...props} />;
54
+ }
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 ADDED
@@ -0,0 +1,144 @@
1
+ // packages/next/src/studio.tsx
2
+ // Mount the Backmatter Studio on a route of the site.
3
+ //
4
+ // // app/(backmatter)/backmatter/layout.tsx
5
+ // export { StudioLayout as default } from "@backmatter/next/studio";
6
+ //
7
+ // // app/(backmatter)/backmatter/[[...path]]/page.tsx
8
+ // import { createStudioPage } from "@backmatter/next/studio";
9
+ // import { cms } from "@/.backmatter";
10
+ // export default createStudioPage(cms);
11
+ //
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.
15
+ import { Suspense, type ReactNode } from "react";
16
+ import {
17
+ PREVIEW_SESSION_COOKIE,
18
+ readPreviewSession,
19
+ resolveDashboardUrl,
20
+ studioEnterUrl,
21
+ } from "@backmatter/core";
22
+ import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
23
+ import studioPkg from "@backmatter/studio/package.json";
24
+ import type { StudioDefinition } from "@backmatter/studio/define";
25
+ import { requestUrlFrom } from "./request-url";
26
+ import { StudioMount } from "./studio-client";
27
+
28
+ export { requestUrlFrom };
29
+
30
+ export type StudioPageOptions = {
31
+ /** Where the Studio is mounted. Default `/backmatter`. */
32
+ basePath?: string;
33
+ /** Dashboard that holds the login. Default: `resolveDashboardUrl()`. */
34
+ dashboardUrl?: string;
35
+ /** Workspace to open. Default: `cms.config.workspaceId`. */
36
+ workspaceId?: string;
37
+ /** Same-origin proxy route. Default `/api/backmatter/cms`. */
38
+ proxyPath?: string;
39
+ /** Ticket entry route. Default `/api/backmatter/preview`. */
40
+ previewEntryPath?: string;
41
+ /**
42
+ * Site customization: the default export of a `"use client"` module that
43
+ * calls `defineStudio`. The server only forwards the reference.
44
+ */
45
+ studio?: StudioDefinition;
46
+ };
47
+
48
+ type NextHeaders = {
49
+ cookies: () => Promise<{ get: (name: string) => { value: string } | undefined }>;
50
+ headers: () => Promise<{ get: (name: string) => string | null }>;
51
+ };
52
+
53
+ type NextNavigation = { redirect: (url: string) => never };
54
+
55
+ async function nextHeaders(): Promise<NextHeaders> {
56
+ return (await import("next/headers" as string)) as NextHeaders;
57
+ }
58
+
59
+ async function nextNavigation(): Promise<NextNavigation> {
60
+ return (await import("next/navigation" as string)) as NextNavigation;
61
+ }
62
+
63
+ export function studioPathFor(basePath: string, segments: string[] | undefined, search = ""): string {
64
+ const rest = segments && segments.length > 0 ? `/${segments.map(encodeURIComponent).join("/")}` : "";
65
+ return `${basePath}${rest}${search}`;
66
+ }
67
+
68
+ export function createStudioPage(
69
+ cms: Pick<BackmatterClient, "config">,
70
+ options: StudioPageOptions = {},
71
+ ) {
72
+ const basePath = (options.basePath ?? "/backmatter").replace(/\/+$/, "") || "/backmatter";
73
+ const proxyPath = options.proxyPath ?? "/api/backmatter/cms";
74
+ const previewEntryPath = options.previewEntryPath ?? "/api/backmatter/preview";
75
+
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
+ }) {
80
+ const workspaceId = options.workspaceId ?? cms.config.workspaceId;
81
+ if (!workspaceId) {
82
+ throw new Error("backmatter: the Studio needs a workspaceId (backmatter.config.ts or BACKMATTER_WORKSPACE_ID)");
83
+ }
84
+ const dashboardUrl = resolveDashboardUrl(options.dashboardUrl);
85
+ const { cookies, headers } = await nextHeaders();
86
+ const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
87
+
88
+ const token = cookieStore.get(PREVIEW_SESSION_COOKIE)?.value;
89
+ const secret = cms.config.previewToken;
90
+ const claims = token && secret ? await readPreviewSession(secret, token) : null;
91
+ const valid = claims !== null && claims.workspaceId === workspaceId;
92
+
93
+ if (!valid) {
94
+ const params = await props.params;
95
+ const search = props.searchParams ? await props.searchParams : {};
96
+ const qs = new URLSearchParams();
97
+ for (const [key, value] of Object.entries(search)) {
98
+ if (typeof value === "string") qs.set(key, value);
99
+ }
100
+ const searchText = qs.size > 0 ? `?${qs.toString()}` : "";
101
+ const returnUrl = requestUrlFrom(
102
+ (name) => headerStore.get(name),
103
+ studioPathFor(basePath, params.path, searchText),
104
+ );
105
+ const { redirect } = await nextNavigation();
106
+ redirect(studioEnterUrl(dashboardUrl, { workspaceId, returnUrl }));
107
+ }
108
+
109
+ return (
110
+ <StudioMount
111
+ workspaceId={workspaceId}
112
+ basePath={basePath}
113
+ dashboardUrl={dashboardUrl}
114
+ version={studioPkg.version}
115
+ proxyPath={proxyPath}
116
+ previewEntryPath={previewEntryPath}
117
+ apiUrl={cms.config.apiUrl}
118
+ studio={options.studio}
119
+ />
120
+ );
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Root layout for the Studio route group. Its own `<html>` so the site's
126
+ * layout, fonts and global CSS do not wrap the editor.
127
+ */
128
+ export function StudioLayout({ children }: { children: ReactNode }) {
129
+ return (
130
+ <html lang="en" suppressHydrationWarning>
131
+ <head>
132
+ <meta name="robots" content="noindex, nofollow" />
133
+ <script dangerouslySetInnerHTML={{ __html: THEME_BOOT }} />
134
+ </head>
135
+ <body style={{ margin: 0 }}>
136
+ {/* The page reads cookies; with Cache Components that must sit under Suspense. */}
137
+ <Suspense fallback={null}>{children}</Suspense>
138
+ </body>
139
+ </html>
140
+ );
141
+ }
142
+
143
+ // Same first-paint theme pick as the dashboard's index.html.
144
+ const THEME_BOOT = `(function(){try{var s=localStorage.getItem("backmatter-theme");var m=s==="light"||s==="dark"?s:"system";var d=m==="dark"||(m!=="light"&&window.matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.dataset.theme=d?"dark":"light";document.documentElement.style.colorScheme=d?"dark":"light"}catch(e){}})();`;