@backmatter/next 0.0.6 → 0.0.8

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.6",
3
+ "version": "0.0.8",
4
4
  "description": "Experimental Next.js adapter for Backmatter. APIs will break. Not ready for production.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -38,7 +38,7 @@
38
38
  "test": "bun test"
39
39
  },
40
40
  "dependencies": {
41
- "@backmatter/core": "^0.0.6",
41
+ "@backmatter/core": "^0.0.8",
42
42
  "@mdx-js/mdx": "^3.1.1",
43
43
  "remark-gfm": "^4.0.1"
44
44
  },
@@ -0,0 +1,280 @@
1
+ // packages/next/src/devbar-client.tsx
2
+ "use client";
3
+
4
+ import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
5
+ import {
6
+ clearHostPreviewSession,
7
+ fetchCmsSession,
8
+ fetchHostPreviewSession,
9
+ resolveDevMode,
10
+ type CmsSessionUser,
11
+ } from "@backmatter/core/preview";
12
+
13
+ export type { CmsSessionUser };
14
+
15
+ export type DevModeState =
16
+ | { status: "hidden" }
17
+ | {
18
+ status: "ready";
19
+ user: CmsSessionUser;
20
+ mode: "preview" | "live";
21
+ branch?: string;
22
+ tree?: string;
23
+ };
24
+
25
+ export function devModeState(
26
+ user: CmsSessionUser | null,
27
+ session: { enabled: boolean; branch?: string; tree?: string },
28
+ ): DevModeState {
29
+ if (!user) return { status: "hidden" };
30
+ const resolved = resolveDevMode(session);
31
+ return {
32
+ status: "ready",
33
+ user,
34
+ ...resolved,
35
+ };
36
+ }
37
+
38
+ export function useDevMode(options: { apiUrl: string }): DevModeState {
39
+ const [state, setState] = useState<DevModeState>({ status: "hidden" });
40
+
41
+ useEffect(() => {
42
+ const apiUrl = options.apiUrl.trim();
43
+ if (!apiUrl) return;
44
+ let cancelled = false;
45
+ void Promise.all([fetchCmsSession(apiUrl), fetchHostPreviewSession("")]).then(
46
+ ([user, session]) => {
47
+ if (cancelled) return;
48
+ setState(devModeState(user, session));
49
+ },
50
+ );
51
+ return () => {
52
+ cancelled = true;
53
+ };
54
+ }, [options.apiUrl]);
55
+
56
+ return state;
57
+ }
58
+
59
+ /** Above typical host sticky headers/modals; below max 32-bit int so a host can still overlay. */
60
+ const DEVBAR_Z_INDEX = 2147483000;
61
+
62
+ const barStyle: CSSProperties = {
63
+ position: "fixed",
64
+ bottom: 0,
65
+ left: "50%",
66
+ transform: "translateX(-50%)",
67
+ zIndex: DEVBAR_Z_INDEX,
68
+ isolation: "isolate",
69
+ pointerEvents: "auto",
70
+ display: "flex",
71
+ alignItems: "center",
72
+ gap: 10,
73
+ minHeight: 32,
74
+ maxWidth: "min(360px, calc(100vw - 24px))",
75
+ padding: "0 12px",
76
+ paddingBottom: "env(safe-area-inset-bottom, 0px)",
77
+ background: "#f4f4f5",
78
+ color: "#71717a",
79
+ border: "1px solid #e4e4e7",
80
+ borderBottom: "none",
81
+ borderTopLeftRadius: 8,
82
+ borderTopRightRadius: 8,
83
+ borderBottomLeftRadius: 0,
84
+ borderBottomRightRadius: 0,
85
+ fontFamily:
86
+ 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
87
+ fontSize: 11,
88
+ fontWeight: 500,
89
+ lineHeight: "32px",
90
+ WebkitFontSmoothing: "antialiased",
91
+ fontVariantNumeric: "tabular-nums",
92
+ boxSizing: "border-box",
93
+ };
94
+
95
+ const brandStyle: CSSProperties = {
96
+ color: "#18181b",
97
+ letterSpacing: "-0.02em",
98
+ };
99
+
100
+ const itemStyle: CSSProperties = {
101
+ display: "inline-flex",
102
+ alignItems: "center",
103
+ gap: 6,
104
+ minWidth: 0,
105
+ };
106
+
107
+ const dividerStyle: CSSProperties = {
108
+ width: 1,
109
+ height: 12,
110
+ flexShrink: 0,
111
+ background: "#e4e4e7",
112
+ };
113
+
114
+ const branchTextStyle: CSSProperties = {
115
+ overflow: "hidden",
116
+ textOverflow: "ellipsis",
117
+ whiteSpace: "nowrap",
118
+ maxWidth: 140,
119
+ color: "#18181b",
120
+ };
121
+
122
+ const exitButtonStyle: CSSProperties = {
123
+ appearance: "none",
124
+ border: "none",
125
+ background: "transparent",
126
+ padding: "0 2px",
127
+ margin: 0,
128
+ cursor: "pointer",
129
+ color: "#18181b",
130
+ fontFamily: "inherit",
131
+ fontSize: 11,
132
+ fontWeight: 500,
133
+ lineHeight: "32px",
134
+ minHeight: 32,
135
+ touchAction: "manipulation",
136
+ WebkitFontSmoothing: "antialiased",
137
+ };
138
+
139
+ function Dot({ color }: { color: string }) {
140
+ return (
141
+ <span
142
+ aria-hidden
143
+ style={{
144
+ width: 6,
145
+ height: 6,
146
+ borderRadius: 999,
147
+ background: color,
148
+ flexShrink: 0,
149
+ }}
150
+ />
151
+ );
152
+ }
153
+
154
+ function BranchIcon() {
155
+ return (
156
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
157
+ <circle cx="4.5" cy="4" r="1.75" stroke="currentColor" strokeWidth="1.25" />
158
+ <circle cx="11.5" cy="12" r="1.75" stroke="currentColor" strokeWidth="1.25" />
159
+ <circle cx="4.5" cy="12" r="1.75" stroke="currentColor" strokeWidth="1.25" />
160
+ <path
161
+ d="M4.5 5.75v4.5M4.5 8.5h4.2a3 3 0 0 1 3 3"
162
+ stroke="currentColor"
163
+ strokeWidth="1.25"
164
+ strokeLinecap="round"
165
+ />
166
+ </svg>
167
+ );
168
+ }
169
+
170
+ function Segment({ children }: { children: ReactNode }) {
171
+ return <span style={itemStyle}>{children}</span>;
172
+ }
173
+
174
+ function Divider() {
175
+ return <span aria-hidden style={dividerStyle} />;
176
+ }
177
+
178
+ export function DevBarView({
179
+ state,
180
+ onExit,
181
+ exiting,
182
+ }: {
183
+ state: DevModeState;
184
+ onExit?: () => void;
185
+ exiting?: boolean;
186
+ }) {
187
+ if (state.status !== "ready") return null;
188
+ const live = state.mode === "live";
189
+ const modeLabel = live ? "Live" : "Preview";
190
+ const draft = Boolean(state.tree);
191
+ const parts = ["backmatter", modeLabel];
192
+ if (state.branch) parts.push(state.branch);
193
+ if (draft) parts.push("draft");
194
+ const ariaLabel = parts.join(", ");
195
+
196
+ return (
197
+ <div role="status" aria-label={ariaLabel} style={barStyle}>
198
+ <style>{`
199
+ @media (hover: hover) {
200
+ button.backmatter-devbar-exit:hover:not(:disabled) { color: #71717a; }
201
+ }
202
+ button.backmatter-devbar-exit:focus-visible {
203
+ outline: 2px solid #18181b;
204
+ outline-offset: 2px;
205
+ }
206
+ button.backmatter-devbar-exit:disabled {
207
+ cursor: default;
208
+ opacity: 0.5;
209
+ }
210
+ `}</style>
211
+ <Segment>
212
+ <span style={brandStyle}>backmatter</span>
213
+ </Segment>
214
+ <Divider />
215
+ <Segment>
216
+ <Dot color={live ? "#059669" : "#2563eb"} />
217
+ <span style={{ color: "#18181b" }}>{modeLabel}</span>
218
+ </Segment>
219
+ {state.branch ? (
220
+ <>
221
+ <Divider />
222
+ <Segment>
223
+ <BranchIcon />
224
+ <span style={branchTextStyle}>{state.branch}</span>
225
+ </Segment>
226
+ </>
227
+ ) : null}
228
+ {draft ? (
229
+ <>
230
+ <Divider />
231
+ <Segment>draft</Segment>
232
+ </>
233
+ ) : null}
234
+ {!live ? (
235
+ <>
236
+ <Divider />
237
+ <button
238
+ type="button"
239
+ className="backmatter-devbar-exit"
240
+ aria-label="Exit preview"
241
+ disabled={exiting}
242
+ onClick={() => onExit?.()}
243
+ style={exitButtonStyle}
244
+ >
245
+ Exit
246
+ </button>
247
+ </>
248
+ ) : null}
249
+ </div>
250
+ );
251
+ }
252
+
253
+ export function DevBarClient({ apiUrl }: { apiUrl: string }) {
254
+ const state = useDevMode({ apiUrl });
255
+ const [exiting, setExiting] = useState(false);
256
+ return (
257
+ <DevBarView
258
+ state={state}
259
+ exiting={exiting}
260
+ onExit={() => {
261
+ if (exiting) return;
262
+ setExiting(true);
263
+ void exitHostPreview().then((ok) => {
264
+ if (!ok) setExiting(false);
265
+ });
266
+ }}
267
+ />
268
+ );
269
+ }
270
+
271
+ export async function exitHostPreview(
272
+ clear: (origin?: string) => Promise<boolean> = clearHostPreviewSession,
273
+ reload: () => void = () => {
274
+ location.reload();
275
+ },
276
+ ): Promise<boolean> {
277
+ const ok = await clear("");
278
+ if (ok) reload();
279
+ return ok;
280
+ }
package/src/devbar.tsx CHANGED
@@ -1,280 +1,18 @@
1
1
  // packages/next/src/devbar.tsx
2
- "use client";
3
-
4
- import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
5
- import {
6
- clearHostPreviewSession,
7
- fetchCmsSession,
8
- fetchHostPreviewSession,
9
- resolveDevMode,
2
+ import { resolveDevBarApiUrl } from "@backmatter/core";
3
+ import { DevBarClient } from "./devbar-client";
4
+
5
+ export {
6
+ DevBarClient,
7
+ DevBarView,
8
+ devModeState,
9
+ exitHostPreview,
10
+ useDevMode,
10
11
  type CmsSessionUser,
11
- } from "@backmatter/core/preview";
12
-
13
- export type { CmsSessionUser };
14
-
15
- export type DevModeState =
16
- | { status: "hidden" }
17
- | {
18
- status: "ready";
19
- user: CmsSessionUser;
20
- mode: "preview" | "live";
21
- branch?: string;
22
- tree?: string;
23
- };
24
-
25
- export function devModeState(
26
- user: CmsSessionUser | null,
27
- session: { enabled: boolean; branch?: string; tree?: string },
28
- ): DevModeState {
29
- if (!user) return { status: "hidden" };
30
- const resolved = resolveDevMode(session);
31
- return {
32
- status: "ready",
33
- user,
34
- ...resolved,
35
- };
36
- }
37
-
38
- export function useDevMode(options: { apiUrl: string }): DevModeState {
39
- const [state, setState] = useState<DevModeState>({ status: "hidden" });
40
-
41
- useEffect(() => {
42
- const apiUrl = options.apiUrl.trim();
43
- if (!apiUrl) return;
44
- let cancelled = false;
45
- void Promise.all([fetchCmsSession(apiUrl), fetchHostPreviewSession("")]).then(
46
- ([user, session]) => {
47
- if (cancelled) return;
48
- setState(devModeState(user, session));
49
- },
50
- );
51
- return () => {
52
- cancelled = true;
53
- };
54
- }, [options.apiUrl]);
55
-
56
- return state;
57
- }
58
-
59
- /** Above typical host sticky headers/modals; below max 32-bit int so a host can still overlay. */
60
- const DEVBAR_Z_INDEX = 2147483000;
61
-
62
- const barStyle: CSSProperties = {
63
- position: "fixed",
64
- bottom: 0,
65
- left: "50%",
66
- transform: "translateX(-50%)",
67
- zIndex: DEVBAR_Z_INDEX,
68
- isolation: "isolate",
69
- pointerEvents: "auto",
70
- display: "flex",
71
- alignItems: "center",
72
- gap: 10,
73
- minHeight: 32,
74
- maxWidth: "min(360px, calc(100vw - 24px))",
75
- padding: "0 12px",
76
- paddingBottom: "env(safe-area-inset-bottom, 0px)",
77
- background: "#f4f4f5",
78
- color: "#71717a",
79
- border: "1px solid #e4e4e7",
80
- borderBottom: "none",
81
- borderTopLeftRadius: 8,
82
- borderTopRightRadius: 8,
83
- borderBottomLeftRadius: 0,
84
- borderBottomRightRadius: 0,
85
- fontFamily:
86
- 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
87
- fontSize: 11,
88
- fontWeight: 500,
89
- lineHeight: "32px",
90
- WebkitFontSmoothing: "antialiased",
91
- fontVariantNumeric: "tabular-nums",
92
- boxSizing: "border-box",
93
- };
94
-
95
- const brandStyle: CSSProperties = {
96
- color: "#18181b",
97
- letterSpacing: "-0.02em",
98
- };
99
-
100
- const itemStyle: CSSProperties = {
101
- display: "inline-flex",
102
- alignItems: "center",
103
- gap: 6,
104
- minWidth: 0,
105
- };
106
-
107
- const dividerStyle: CSSProperties = {
108
- width: 1,
109
- height: 12,
110
- flexShrink: 0,
111
- background: "#e4e4e7",
112
- };
113
-
114
- const branchTextStyle: CSSProperties = {
115
- overflow: "hidden",
116
- textOverflow: "ellipsis",
117
- whiteSpace: "nowrap",
118
- maxWidth: 140,
119
- color: "#18181b",
120
- };
121
-
122
- const exitButtonStyle: CSSProperties = {
123
- appearance: "none",
124
- border: "none",
125
- background: "transparent",
126
- padding: "0 2px",
127
- margin: 0,
128
- cursor: "pointer",
129
- color: "#18181b",
130
- fontFamily: "inherit",
131
- fontSize: 11,
132
- fontWeight: 500,
133
- lineHeight: "32px",
134
- minHeight: 32,
135
- touchAction: "manipulation",
136
- WebkitFontSmoothing: "antialiased",
137
- };
138
-
139
- function Dot({ color }: { color: string }) {
140
- return (
141
- <span
142
- aria-hidden
143
- style={{
144
- width: 6,
145
- height: 6,
146
- borderRadius: 999,
147
- background: color,
148
- flexShrink: 0,
149
- }}
150
- />
151
- );
152
- }
153
-
154
- function BranchIcon() {
155
- return (
156
- <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
157
- <circle cx="4.5" cy="4" r="1.75" stroke="currentColor" strokeWidth="1.25" />
158
- <circle cx="11.5" cy="12" r="1.75" stroke="currentColor" strokeWidth="1.25" />
159
- <circle cx="4.5" cy="12" r="1.75" stroke="currentColor" strokeWidth="1.25" />
160
- <path
161
- d="M4.5 5.75v4.5M4.5 8.5h4.2a3 3 0 0 1 3 3"
162
- stroke="currentColor"
163
- strokeWidth="1.25"
164
- strokeLinecap="round"
165
- />
166
- </svg>
167
- );
168
- }
169
-
170
- function Segment({ children }: { children: ReactNode }) {
171
- return <span style={itemStyle}>{children}</span>;
172
- }
173
-
174
- function Divider() {
175
- return <span aria-hidden style={dividerStyle} />;
176
- }
177
-
178
- export function DevBarView({
179
- state,
180
- onExit,
181
- exiting,
182
- }: {
183
- state: DevModeState;
184
- onExit?: () => void;
185
- exiting?: boolean;
186
- }) {
187
- if (state.status !== "ready") return null;
188
- const live = state.mode === "live";
189
- const modeLabel = live ? "Live" : "Preview";
190
- const draft = Boolean(state.tree);
191
- const parts = ["backmatter", modeLabel];
192
- if (state.branch) parts.push(state.branch);
193
- if (draft) parts.push("draft");
194
- const ariaLabel = parts.join(", ");
195
-
196
- return (
197
- <div role="status" aria-label={ariaLabel} style={barStyle}>
198
- <style>{`
199
- @media (hover: hover) {
200
- button.backmatter-devbar-exit:hover:not(:disabled) { color: #71717a; }
201
- }
202
- button.backmatter-devbar-exit:focus-visible {
203
- outline: 2px solid #18181b;
204
- outline-offset: 2px;
205
- }
206
- button.backmatter-devbar-exit:disabled {
207
- cursor: default;
208
- opacity: 0.5;
209
- }
210
- `}</style>
211
- <Segment>
212
- <span style={brandStyle}>backmatter</span>
213
- </Segment>
214
- <Divider />
215
- <Segment>
216
- <Dot color={live ? "#059669" : "#2563eb"} />
217
- <span style={{ color: "#18181b" }}>{modeLabel}</span>
218
- </Segment>
219
- {state.branch ? (
220
- <>
221
- <Divider />
222
- <Segment>
223
- <BranchIcon />
224
- <span style={branchTextStyle}>{state.branch}</span>
225
- </Segment>
226
- </>
227
- ) : null}
228
- {draft ? (
229
- <>
230
- <Divider />
231
- <Segment>draft</Segment>
232
- </>
233
- ) : null}
234
- {!live ? (
235
- <>
236
- <Divider />
237
- <button
238
- type="button"
239
- className="backmatter-devbar-exit"
240
- aria-label="Exit preview"
241
- disabled={exiting}
242
- onClick={() => onExit?.()}
243
- style={exitButtonStyle}
244
- >
245
- Exit
246
- </button>
247
- </>
248
- ) : null}
249
- </div>
250
- );
251
- }
252
-
253
- export function DevBar({ apiUrl }: { apiUrl: string }) {
254
- const state = useDevMode({ apiUrl });
255
- const [exiting, setExiting] = useState(false);
256
- return (
257
- <DevBarView
258
- state={state}
259
- exiting={exiting}
260
- onExit={() => {
261
- if (exiting) return;
262
- setExiting(true);
263
- void exitHostPreview().then((ok) => {
264
- if (!ok) setExiting(false);
265
- });
266
- }}
267
- />
268
- );
269
- }
12
+ type DevModeState,
13
+ } from "./devbar-client";
270
14
 
271
- export async function exitHostPreview(
272
- clear: (origin?: string) => Promise<boolean> = clearHostPreviewSession,
273
- reload: () => void = () => {
274
- location.reload();
275
- },
276
- ): Promise<boolean> {
277
- const ok = await clear("");
278
- if (ok) reload();
279
- return ok;
15
+ /** Host layout: `<DevBar />`. Optional apiUrl for local/self-hosted CMS. */
16
+ export function DevBar({ apiUrl }: { apiUrl?: string } = {}) {
17
+ return <DevBarClient apiUrl={resolveDevBarApiUrl(apiUrl)} />;
280
18
  }
@@ -2,7 +2,7 @@
2
2
  "use client";
3
3
 
4
4
  import { useEffect, useState, type ReactNode } from "react";
5
- import type { BackmatterDoc } from "@backmatter/core";
5
+ import { PREVIEW_CHECKOUT_QUERY, type BackmatterDoc } from "@backmatter/core";
6
6
  import type { PreviewChangeEvent } from "@backmatter/core/preview";
7
7
  import { MdxBody, type MdxComponents } from "./mdx-client";
8
8
 
@@ -22,6 +22,8 @@ function streamUrl(ref: { locale: string; type: string; slug: string }) {
22
22
  type: ref.type,
23
23
  slug: ref.slug,
24
24
  });
25
+ const checkout = new URLSearchParams(globalThis.location?.search ?? "").get(PREVIEW_CHECKOUT_QUERY);
26
+ if (checkout) params.set(PREVIEW_CHECKOUT_QUERY, checkout);
25
27
  return `/api/backmatter/preview?${params}`;
26
28
  }
27
29
 
@@ -1,7 +1,9 @@
1
1
  // packages/next/src/preview-route.ts
2
2
  import {
3
+ PREVIEW_CHECKOUT_QUERY,
3
4
  PREVIEW_COOKIE,
4
5
  PREVIEW_COOKIE_TTL_MS,
6
+ previewLocationWithCheckout,
5
7
  readPreviewCookie,
6
8
  readPreviewTicket,
7
9
  writePreviewCookie,
@@ -17,14 +19,20 @@ export type PreviewRouteDeps = {
17
19
  };
18
20
 
19
21
  function cookieBase(secure: boolean, maxAge: number, value: string) {
22
+ // HTTPS is cross-site from the CMS iframe (dash → customer origin). Lax is
23
+ // not sent there, so draftMode can be on while the checkout cookie is missing
24
+ // (`invalid_session`). Partitioned (CHIPS) keeps the cookie in that embed.
20
25
  const parts = [
21
26
  `${PREVIEW_COOKIE}=${value}`,
22
27
  "Path=/",
23
28
  "HttpOnly",
24
- "SameSite=Lax",
25
29
  `Max-Age=${maxAge}`,
26
30
  ];
27
- if (secure) parts.push("Secure");
31
+ if (secure) {
32
+ parts.push("SameSite=None", "Secure", "Partitioned");
33
+ } else {
34
+ parts.push("SameSite=Lax");
35
+ }
28
36
  return parts.join("; ");
29
37
  }
30
38
 
@@ -47,6 +55,13 @@ function cookieFrom(req: Request): string | undefined {
47
55
  return undefined;
48
56
  }
49
57
 
58
+ async function checkoutFrom(req: Request, secret: string, url: URL): Promise<PreviewCheckout | null> {
59
+ return (
60
+ (await readPreviewCookie(secret, cookieFrom(req))) ??
61
+ (await readPreviewCookie(secret, url.searchParams.get(PREVIEW_CHECKOUT_QUERY)?.trim()))
62
+ );
63
+ }
64
+
50
65
  export function createPreviewRoute(
51
66
  cms: Pick<BackmatterClient, "config">,
52
67
  deps: PreviewRouteDeps = {},
@@ -59,7 +74,7 @@ export function createPreviewRoute(
59
74
  const secure = url.protocol === "https:";
60
75
 
61
76
  if (url.searchParams.get("session") === "1") {
62
- const checkout = secret ? await readPreviewCookie(secret, cookieFrom(req)) : null;
77
+ const checkout = secret ? await checkoutFrom(req, secret, url) : null;
63
78
  if (!checkout) return Response.json({ mode: "live" });
64
79
  return Response.json({
65
80
  mode: "preview",
@@ -71,7 +86,7 @@ export function createPreviewRoute(
71
86
  if (!secret || !cms.config.apiUrl || !cms.config.workspaceId) {
72
87
  return new Response("preview not configured", { status: 503 });
73
88
  }
74
- const checkout = await readPreviewCookie(secret, cookieFrom(req));
89
+ const checkout = await checkoutFrom(req, secret, url);
75
90
  if (!checkout) return new Response("preview session missing", { status: 401 });
76
91
  const locale = url.searchParams.get("locale") ?? cms.config.defaultLocale;
77
92
  const type = url.searchParams.get("type") ?? "";
@@ -104,7 +119,7 @@ export function createPreviewRoute(
104
119
  exp: now() + PREVIEW_COOKIE_TTL_MS,
105
120
  });
106
121
  await (deps.enableDraft ?? (() => nextDraft(true)))();
107
- const location = new URL(payload.redirect, url.origin).toString();
122
+ const location = previewLocationWithCheckout(url.origin, payload.redirect, value);
108
123
  return new Response(null, {
109
124
  status: 302,
110
125
  headers: {
package/src/routes.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // `route` template (including `/` via indexSlug), loads it and renders MDX
5
5
  // or JSON pages. An optional catch-all is required so `/` is a CMS route.
6
6
  import { Suspense, createElement, type ReactNode } from "react";
7
- import { matchRoute, modelFormat, PREVIEW_COOKIE, readPreviewCookie, routeSegments, type BackmatterDoc, type ModelDef } from "@backmatter/core";
7
+ import { matchRoute, modelFormat, PREVIEW_CHECKOUT_QUERY, PREVIEW_COOKIE, readPreviewCookie, routeSegments, type BackmatterDoc, type ModelDef } from "@backmatter/core";
8
8
  import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
9
9
  import { fetchPreviewDoc } from "@backmatter/core/preview";
10
10
  import { renderDoc, type MdxComponents } from "./mdx-compile";
@@ -58,9 +58,17 @@ export type CatchAllOptions = {
58
58
 
59
59
  export type CatchAllMetadata = { title?: string };
60
60
 
61
+ export type CatchAllSearchParams = Record<string, string | string[] | undefined>;
62
+
61
63
  export type CatchAllRoute = {
62
- Page: (props: { params: Promise<CatchAllParams> }) => ReactNode;
63
- CmsRoute: (props: { params: Promise<CatchAllParams> | CatchAllParams }) => Promise<ReactNode>;
64
+ Page: (props: {
65
+ params: Promise<CatchAllParams>;
66
+ searchParams?: Promise<CatchAllSearchParams>;
67
+ }) => ReactNode;
68
+ CmsRoute: (props: {
69
+ params: Promise<CatchAllParams> | CatchAllParams;
70
+ searchParams?: Promise<CatchAllSearchParams> | CatchAllSearchParams;
71
+ }) => Promise<ReactNode>;
64
72
  generateStaticParams: () => Promise<CatchAllParams[]>;
65
73
  generateMetadata: (props: {
66
74
  params: Promise<CatchAllParams>;
@@ -81,9 +89,21 @@ type PreviewState =
81
89
  | { enabled: false }
82
90
  | { enabled: true; checkout: { branch?: string; tree?: string }; error?: string };
83
91
 
92
+ async function checkoutQueryToken(
93
+ searchParams: Promise<CatchAllSearchParams> | CatchAllSearchParams | undefined,
94
+ ): Promise<string | undefined> {
95
+ if (!searchParams) return undefined;
96
+ const query = await searchParams;
97
+ const raw = query[PREVIEW_CHECKOUT_QUERY];
98
+ const value = Array.isArray(raw) ? raw[0] : raw;
99
+ const trimmed = value?.trim();
100
+ return trimmed ? trimmed : undefined;
101
+ }
102
+
84
103
  async function resolvePreviewState(
85
104
  cms: Pick<BackmatterClient, "config">,
86
105
  injected: CatchAllOptions["preview"],
106
+ searchParams?: Promise<CatchAllSearchParams> | CatchAllSearchParams,
87
107
  ): Promise<PreviewState> {
88
108
  if (injected) {
89
109
  if (!injected.enabled) return { enabled: false };
@@ -99,7 +119,9 @@ async function resolvePreviewState(
99
119
  const secret = cms.config.previewToken;
100
120
  if (!secret) return { enabled: true, checkout: {}, error: "preview_not_configured" };
101
121
  const raw = (await mod.cookies()).get(PREVIEW_COOKIE)?.value;
102
- const checkout = await readPreviewCookie(secret, raw);
122
+ const checkout =
123
+ (await readPreviewCookie(secret, raw)) ??
124
+ (await readPreviewCookie(secret, await checkoutQueryToken(searchParams)));
103
125
  if (!checkout) return { enabled: true, checkout: {}, error: "invalid_session" };
104
126
  return { enabled: true, checkout };
105
127
  } catch {
@@ -137,12 +159,12 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
137
159
 
138
160
  const PageFallback = BlockSkeleton;
139
161
 
140
- const CmsRoute: CatchAllRoute["CmsRoute"] = async ({ params }) => {
162
+ const CmsRoute: CatchAllRoute["CmsRoute"] = async ({ params, searchParams }) => {
141
163
  const resolved = await params;
142
164
  const hit = resolve(resolved[CATCH_ALL_PARAM]);
143
165
  const fail = options.notFound ?? nextNotFound;
144
166
  if (!hit) return fail();
145
- const preview = await resolvePreviewState(cms, options.preview);
167
+ const preview = await resolvePreviewState(cms, options.preview, searchParams);
146
168
  let doc: BackmatterDoc | null;
147
169
  if (preview.enabled) {
148
170
  if (preview.error) return previewUnavailable(preview.error);