@backmatter/next 0.0.22 → 0.0.24

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.24",
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.2"
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,20 +1,34 @@
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,
9
8
  fetchHostPreviewSession,
10
9
  resolveDevMode,
11
10
  type CmsSessionUser,
11
+ type HostSessionReason,
12
12
  } from "@backmatter/core/preview";
13
+ import {
14
+ ENTER_ATTEMPT_KEY,
15
+ enterPath,
16
+ hasMarker,
17
+ hasOptInParam,
18
+ isGateShortcut,
19
+ markerCookie,
20
+ recentEnterAttempt,
21
+ stripOptInParam,
22
+ } from "./gate";
23
+ import { DEVBAR_CLASS, DEVBAR_ENTER_CSS, devbarShellStyle } from "./devbar-motion";
24
+
25
+ /** The bar and its panels load only after the gate opens. */
26
+ const SiteChrome = lazy(() => import("./site-editor").then((m) => ({ default: m.SiteChrome })));
13
27
 
14
28
  export type { CmsSessionUser };
15
29
 
16
30
  export type DevModeState =
17
- | { status: "hidden" }
31
+ | { status: "hidden"; reason?: HostSessionReason }
18
32
  | {
19
33
  status: "ready";
20
34
  user?: CmsSessionUser;
@@ -23,11 +37,33 @@ export type DevModeState =
23
37
  tree?: string;
24
38
  };
25
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
+
26
60
  export function devModeState(
27
61
  user: CmsSessionUser | null,
28
- session: { enabled: boolean; branch?: string; tree?: string },
62
+ session: { enabled: boolean; branch?: string; tree?: string; reason?: HostSessionReason },
29
63
  ): DevModeState {
30
- if (!user && !session.enabled) return { status: "hidden" };
64
+ if (!user && !session.enabled) {
65
+ return session.reason ? { status: "hidden", reason: session.reason } : { status: "hidden" };
66
+ }
31
67
  const resolved = resolveDevMode(session);
32
68
  return {
33
69
  status: "ready",
@@ -66,40 +102,15 @@ export function useDevMode(options: { apiUrl: string }): DevModeState {
66
102
  return state;
67
103
  }
68
104
 
69
- /** Above typical host sticky headers/modals; below max 32-bit int so a host can still overlay. */
70
- const DEVBAR_Z_INDEX = 2147483000;
71
-
72
105
  const barStyle: CSSProperties = {
73
- position: "fixed",
74
- bottom: 0,
75
- left: "50%",
76
- transform: "translateX(-50%)",
77
- zIndex: DEVBAR_Z_INDEX,
78
- isolation: "isolate",
106
+ ...devbarShellStyle,
79
107
  pointerEvents: "auto",
80
- display: "flex",
81
- alignItems: "center",
82
108
  gap: 10,
83
- minHeight: 32,
84
- maxWidth: "min(520px, calc(100vw - 24px))",
85
109
  padding: "0 12px",
86
- paddingBottom: "env(safe-area-inset-bottom, 0px)",
87
- background: "#f4f4f5",
88
- color: "#71717a",
89
- border: "1px solid #e4e4e7",
90
- borderBottom: "none",
91
- borderTopLeftRadius: 8,
92
- borderTopRightRadius: 8,
93
- borderBottomLeftRadius: 0,
94
- borderBottomRightRadius: 0,
95
- fontFamily:
96
- 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
97
- fontSize: 11,
110
+ fontFamily: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
111
+ fontSize: 12,
98
112
  fontWeight: 500,
99
- lineHeight: "32px",
100
- WebkitFontSmoothing: "antialiased",
101
- fontVariantNumeric: "tabular-nums",
102
- boxSizing: "border-box",
113
+ lineHeight: "44px",
103
114
  };
104
115
 
105
116
  const brandStyle: CSSProperties = {
@@ -140,8 +151,8 @@ const exitButtonStyle: CSSProperties = {
140
151
  fontFamily: "inherit",
141
152
  fontSize: 11,
142
153
  fontWeight: 500,
143
- lineHeight: "32px",
144
- minHeight: 32,
154
+ lineHeight: "44px",
155
+ minHeight: 44,
145
156
  touchAction: "manipulation",
146
157
  WebkitFontSmoothing: "antialiased",
147
158
  };
@@ -206,8 +217,9 @@ export function DevBarView({
206
217
  const ariaLabel = parts.join(", ");
207
218
 
208
219
  return (
209
- <div role="status" aria-label={ariaLabel} style={barStyle}>
220
+ <div role="status" aria-label={ariaLabel} className={DEVBAR_CLASS} style={barStyle}>
210
221
  <style>{`
222
+ ${DEVBAR_ENTER_CSS}
211
223
  @media (hover: hover) {
212
224
  button.backmatter-devbar-exit:hover:not(:disabled) { color: #71717a; }
213
225
  }
@@ -276,8 +288,102 @@ export function DevBarView({
276
288
  );
277
289
  }
278
290
 
279
- export function DevBarClient({ apiUrl }: { apiUrl: string }) {
280
- const state = useDevMode({ apiUrl });
291
+ const DEFAULT_PREVIEW_ENTRY_PATH = "/api/backmatter/preview";
292
+
293
+ type Gate = "closed" | "resume" | "enter";
294
+
295
+ /**
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.
299
+ */
300
+ function useGate(): Gate {
301
+ const [gate, setGate] = useState<Gate>("closed");
302
+
303
+ useEffect(() => {
304
+ // Framed by an editor (the Studio's split preview, the dashboard's split
305
+ // view): that editor is the chrome, so the page shows no bar of its own.
306
+ if (window.self !== window.top) return;
307
+ const secure = location.protocol === "https:";
308
+ const explicit = hasOptInParam(location.search);
309
+ if (explicit || hasMarker(document.cookie)) {
310
+ document.cookie = markerCookie(true, secure);
311
+ const clean = stripOptInParam(location.href);
312
+ if (clean) history.replaceState(history.state, "", clean);
313
+ setGate(explicit ? "enter" : "resume");
314
+ return;
315
+ }
316
+ const onKey = (e: KeyboardEvent) => {
317
+ if (!isGateShortcut(e)) return;
318
+ e.preventDefault();
319
+ document.cookie = markerCookie(true, secure);
320
+ setGate("enter");
321
+ };
322
+ window.addEventListener("keydown", onKey);
323
+ return () => window.removeEventListener("keydown", onKey);
324
+ }, []);
325
+
326
+ return gate;
327
+ }
328
+
329
+ /**
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.
333
+ */
334
+ function useSiteSession(gate: Gate, previewEntryPath: string): DevModeState {
335
+ const [state, setState] = useState<DevModeState>({ status: "hidden" });
336
+
337
+ useEffect(() => {
338
+ if (gate === "closed") return;
339
+ let cancelled = false;
340
+ void loadDevModeState("").then((next) => {
341
+ if (cancelled) return;
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") {
351
+ sessionStorage.removeItem(ENTER_ATTEMPT_KEY);
352
+ setState(next);
353
+ return;
354
+ }
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));
362
+ return;
363
+ }
364
+ sessionStorage.removeItem(ENTER_ATTEMPT_KEY);
365
+ document.cookie = markerCookie(false, location.protocol === "https:");
366
+ setState({ status: "hidden", reason });
367
+ });
368
+ return () => {
369
+ cancelled = true;
370
+ };
371
+ }, [gate, previewEntryPath]);
372
+
373
+ return state;
374
+ }
375
+
376
+ export function DevBarClient({
377
+ studioPath,
378
+ previewEntryPath = DEFAULT_PREVIEW_ENTRY_PATH,
379
+ }: {
380
+ /** Unused since the bar stopped calling the CMS origin; kept for host layouts that still pass it. */
381
+ apiUrl?: string;
382
+ studioPath?: string;
383
+ previewEntryPath?: string;
384
+ }) {
385
+ const gate = useGate();
386
+ const state = useSiteSession(gate, previewEntryPath);
281
387
  const [exiting, setExiting] = useState(false);
282
388
  const exit = () => {
283
389
  if (exiting) return;
@@ -286,8 +392,20 @@ export function DevBarClient({ apiUrl }: { apiUrl: string }) {
286
392
  if (!ok) setExiting(false);
287
393
  });
288
394
  };
289
- if (state.status === "ready" && state.mode === "preview") {
290
- return <SiteChrome branch={state.branch} tree={state.tree} exiting={exiting} onExit={exit} />;
395
+ if (state.status !== "ready") return null;
396
+ if (state.mode === "preview") {
397
+ return (
398
+ <Suspense fallback={null}>
399
+ <SiteChrome
400
+ branch={state.branch}
401
+ tree={state.tree}
402
+ exiting={exiting}
403
+ onExit={exit}
404
+ studioPath={studioPath}
405
+ previewEntryPath={previewEntryPath}
406
+ />
407
+ </Suspense>
408
+ );
291
409
  }
292
410
  return <DevBarView state={state} exiting={exiting} onExit={exit} />;
293
411
  }
@@ -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,12 +8,33 @@ 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
- /** Host layout: `<DevBar />`. Optional apiUrl for local/self-hosted CMS. */
17
- export function DevBar({ apiUrl }: { apiUrl?: string } = {}) {
18
- return <DevBarClient apiUrl={resolveDevBarApiUrl(apiUrl)} />;
18
+ /**
19
+ * Host layout: `<DevBar />`. Public visitors get a gate of a few hundred
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.
25
+ * `previewEntryPath` is the ticket route (default `/api/backmatter/preview`).
26
+ * `apiUrl` is accepted for older layouts and no longer used.
27
+ */
28
+ export function DevBar({
29
+ apiUrl,
30
+ studioPath,
31
+ previewEntryPath,
32
+ }: { apiUrl?: string; studioPath?: string; previewEntryPath?: string } = {}) {
33
+ return (
34
+ <DevBarClient
35
+ apiUrl={resolveDevBarApiUrl(apiUrl)}
36
+ studioPath={studioPath}
37
+ previewEntryPath={previewEntryPath}
38
+ />
39
+ );
19
40
  }
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
+ }
@@ -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);