@backmatter/next 0.0.27 → 0.0.28

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.27",
3
+ "version": "0.0.28",
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.21",
50
+ "@backmatter/core": "^0.0.22",
51
51
  "@mdx-js/mdx": "^3.1.1",
52
52
  "remark-gfm": "^4.0.1",
53
- "@backmatter/studio": "^0.0.5"
53
+ "@backmatter/studio": "^0.0.6"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "next": ">=16.3.0",
@@ -6,6 +6,8 @@ import {
6
6
  clearHostPreviewSession,
7
7
  fetchCmsSession,
8
8
  fetchHostPreviewSession,
9
+ setHostSiteLive,
10
+ setHostSitePreview,
9
11
  resolveDevMode,
10
12
  type CmsSessionUser,
11
13
  type HostSessionReason,
@@ -47,6 +49,7 @@ export type SessionGateAction = "show" | "bounce" | "quiet" | "wait";
47
49
  */
48
50
  export function sessionGateAction(input: {
49
51
  explicit: boolean;
52
+ /** Host session verified (preview or signed-in live). */
50
53
  enabled: boolean;
51
54
  reason: HostSessionReason;
52
55
  recentAttempt: boolean;
@@ -59,9 +62,15 @@ export function sessionGateAction(input: {
59
62
 
60
63
  export function devModeState(
61
64
  user: CmsSessionUser | null,
62
- session: { enabled: boolean; branch?: string; tree?: string; reason?: HostSessionReason },
65
+ session: {
66
+ enabled: boolean;
67
+ signedIn?: boolean;
68
+ branch?: string;
69
+ tree?: string;
70
+ reason?: HostSessionReason;
71
+ },
63
72
  ): DevModeState {
64
- if (!user && !session.enabled) {
73
+ if (!user && !session.enabled && !session.signedIn) {
65
74
  return session.reason ? { status: "hidden", reason: session.reason } : { status: "hidden" };
66
75
  }
67
76
  const resolved = resolveDevMode(session);
@@ -339,11 +348,11 @@ function useSiteSession(gate: Gate, previewEntryPath: string): DevModeState {
339
348
  let cancelled = false;
340
349
  void loadDevModeState("").then((next) => {
341
350
  if (cancelled) return;
342
- const enabled = next.status === "ready" && next.mode === "preview";
351
+ const showBar = next.status === "ready";
343
352
  const reason = next.status === "hidden" ? (next.reason ?? "anonymous") : "anonymous";
344
353
  const action = sessionGateAction({
345
354
  explicit: gate === "enter",
346
- enabled,
355
+ enabled: showBar,
347
356
  reason,
348
357
  recentAttempt: recentEnterAttempt(sessionStorage.getItem(ENTER_ATTEMPT_KEY), Date.now()),
349
358
  });
@@ -376,38 +385,48 @@ function useSiteSession(gate: Gate, previewEntryPath: string): DevModeState {
376
385
  export function DevBarClient({
377
386
  studioPath,
378
387
  previewEntryPath = DEFAULT_PREVIEW_ENTRY_PATH,
388
+ previewFrame,
379
389
  }: {
380
390
  /** Unused since the bar stopped calling the CMS origin; kept for host layouts that still pass it. */
381
391
  apiUrl?: string;
382
392
  studioPath?: string;
383
393
  previewEntryPath?: string;
394
+ previewFrame?: boolean;
384
395
  }) {
385
396
  const gate = useGate();
386
397
  const state = useSiteSession(gate, previewEntryPath);
387
398
  const [exiting, setExiting] = useState(false);
388
- const exit = () => {
399
+ const [entering, setEntering] = useState(false);
400
+ const goLive = () => {
389
401
  if (exiting) return;
390
402
  setExiting(true);
391
- void exitHostPreview().then((ok) => {
403
+ void goHostSiteLive().then((ok) => {
392
404
  if (!ok) setExiting(false);
393
405
  });
394
406
  };
407
+ const goPreview = () => {
408
+ if (entering) return;
409
+ setEntering(true);
410
+ void enterHostPreview().then((ok) => {
411
+ if (!ok) setEntering(false);
412
+ });
413
+ };
395
414
  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
- );
409
- }
410
- return <DevBarView state={state} exiting={exiting} onExit={exit} />;
415
+ return (
416
+ <Suspense fallback={null}>
417
+ <SiteChrome
418
+ branch={state.branch}
419
+ tree={state.tree}
420
+ siteMode={state.mode}
421
+ exiting={exiting || entering}
422
+ onExit={goLive}
423
+ onEnterPreview={goPreview}
424
+ studioPath={studioPath}
425
+ previewEntryPath={previewEntryPath}
426
+ previewFrame={state.mode === "preview" ? previewFrame : false}
427
+ />
428
+ </Suspense>
429
+ );
411
430
  }
412
431
 
413
432
  export async function exitHostPreview(
@@ -420,3 +439,25 @@ export async function exitHostPreview(
420
439
  if (ok) reload();
421
440
  return ok;
422
441
  }
442
+
443
+ export async function goHostSiteLive(
444
+ live: (origin?: string) => Promise<boolean> = setHostSiteLive,
445
+ reload: () => void = () => {
446
+ location.reload();
447
+ },
448
+ ): Promise<boolean> {
449
+ const ok = await live("");
450
+ if (ok) reload();
451
+ return ok;
452
+ }
453
+
454
+ export async function enterHostPreview(
455
+ preview: (origin?: string) => Promise<boolean> = setHostSitePreview,
456
+ reload: () => void = () => {
457
+ location.reload();
458
+ },
459
+ ): Promise<boolean> {
460
+ const ok = await preview("");
461
+ if (ok) reload();
462
+ return ok;
463
+ }
package/src/devbar.tsx CHANGED
@@ -18,23 +18,31 @@ export {
18
18
  /**
19
19
  * Host layout: `<DevBar />`. Public visitors get a gate of a few hundred
20
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
21
+ * verifies (preview or signed-in live). `?backmatter=1` or ⌘⇧E with no session sends the whole window to
22
22
  * the dashboard; a leftover `bm_ui` marker stays on the public page.
23
23
  * `studioPath` is where `@backmatter/studio` is mounted on this site
24
24
  * (default `/backmatter`); the bar's panels are frames of that route.
25
25
  * `previewEntryPath` is the ticket route (default `/api/backmatter/preview`).
26
+ * `previewFrame` draws a viewport ring and top pill while preview is active.
26
27
  * `apiUrl` is accepted for older layouts and no longer used.
27
28
  */
28
29
  export function DevBar({
29
30
  apiUrl,
30
31
  studioPath,
31
32
  previewEntryPath,
32
- }: { apiUrl?: string; studioPath?: string; previewEntryPath?: string } = {}) {
33
+ previewFrame,
34
+ }: {
35
+ apiUrl?: string;
36
+ studioPath?: string;
37
+ previewEntryPath?: string;
38
+ previewFrame?: boolean;
39
+ } = {}) {
33
40
  return (
34
41
  <DevBarClient
35
42
  apiUrl={resolveDevBarApiUrl(apiUrl)}
36
43
  studioPath={studioPath}
37
44
  previewEntryPath={previewEntryPath}
45
+ previewFrame={previewFrame}
38
46
  />
39
47
  );
40
48
  }
@@ -179,8 +179,25 @@ export function createPreviewRoute(
179
179
  );
180
180
  }
181
181
  if (verdict !== "ok") return clearLogin();
182
+ const draftOn = await (deps.isDraftEnabled ?? nextDraftEnabled)();
183
+ const checkout = secret ? await checkoutFrom(req, secret, url) : null;
184
+ if (draftOn && checkout) {
185
+ return Response.json(
186
+ {
187
+ mode: "preview",
188
+ branch: checkout.branch ?? claims.branch,
189
+ tree: checkout.tree ?? claims.tree,
190
+ },
191
+ { headers: { "Cache-Control": "no-store" } },
192
+ );
193
+ }
182
194
  return Response.json(
183
- { mode: "preview", branch: claims.branch, tree: claims.tree },
195
+ {
196
+ mode: "live",
197
+ signedIn: true,
198
+ branch: claims.branch,
199
+ tree: claims.tree,
200
+ },
184
201
  { headers: { "Cache-Control": "no-store" } },
185
202
  );
186
203
  }
@@ -310,8 +327,43 @@ export function createPreviewRoute(
310
327
  return new Response(null, { status: 204, headers });
311
328
  }
312
329
 
330
+ const secure = url.protocol === "https:";
331
+
332
+ if (url.searchParams.get("live") === "1") {
333
+ await (deps.disableDraft ?? (() => nextDraft(false)))();
334
+ const headers = new Headers();
335
+ headers.append("Set-Cookie", cookieBase(PREVIEW_COOKIE, secure, 0, ""));
336
+ return new Response(null, { status: 204, headers });
337
+ }
338
+
339
+ if (url.searchParams.get("preview") === "1") {
340
+ const token = cookieFrom(req, PREVIEW_SESSION_COOKIE);
341
+ const claims = secret && token ? await readPreviewSession(secret, token) : null;
342
+ const workspaceOk = !cms.config.workspaceId || claims?.workspaceId === cms.config.workspaceId;
343
+ if (!claims || !token || !workspaceOk) {
344
+ return Response.json({ error: "unauthorized" }, { status: 401 });
345
+ }
346
+ const confirm = deps.confirmSession ?? ((current: string) => confirmSessionOnApi(cms.config.apiUrl, current));
347
+ const verdict = await confirm(token);
348
+ if (verdict !== "ok") {
349
+ return Response.json({ error: "unauthorized" }, { status: 401 });
350
+ }
351
+ const value = await writePreviewCookie(secret, {
352
+ branch: claims.branch,
353
+ tree: claims.tree,
354
+ exp: now() + PREVIEW_COOKIE_TTL_MS,
355
+ });
356
+ await (deps.enableDraft ?? (() => nextDraft(true)))();
357
+ const headers = new Headers();
358
+ headers.append(
359
+ "Set-Cookie",
360
+ cookieBase(PREVIEW_COOKIE, secure, Math.floor(PREVIEW_COOKIE_TTL_MS / 1000), value),
361
+ );
362
+ headers.append("Set-Cookie", markerCookieHeader(secure, true));
363
+ return new Response(null, { status: 204, headers });
364
+ }
365
+
313
366
  await (deps.disableDraft ?? (() => nextDraft(false)))();
314
- const secure = new URL(req.url).protocol === "https:";
315
367
  const headers = new Headers();
316
368
  headers.append("Set-Cookie", cookieBase(PREVIEW_COOKIE, secure, 0, ""));
317
369
  headers.append("Set-Cookie", cookieBase(PREVIEW_SESSION_COOKIE, secure, 0, ""));
@@ -62,18 +62,24 @@ export const DEFAULT_STUDIO_PATH = "/backmatter";
62
62
  export function SiteChrome({
63
63
  branch,
64
64
  tree,
65
+ siteMode = "preview",
65
66
  exiting,
66
67
  onExit,
68
+ onEnterPreview,
67
69
  previewEntryPath = DEFAULT_PREVIEW_ENTRY_PATH,
70
+ previewFrame,
68
71
  }: {
69
72
  branch?: string;
70
73
  tree?: string;
74
+ siteMode?: "preview" | "live";
71
75
  exiting?: boolean;
72
76
  onExit: () => void;
77
+ onEnterPreview?: () => void;
73
78
  /** Kept so existing hosts can still pass it. The bar does not open the document editor. */
74
79
  studioPath?: string;
75
80
  /** Ticket route. `POST ?renew=1` slides the site session. A 401 drops the bar. */
76
81
  previewEntryPath?: string;
82
+ previewFrame?: boolean;
77
83
  }) {
78
84
  const router = useRouter();
79
85
  const doc = useSiteDoc();
@@ -125,10 +131,14 @@ export function SiteChrome({
125
131
  doc={doc}
126
132
  branch={branch}
127
133
  tree={tree}
134
+ siteMode={siteMode}
128
135
  exiting={exiting}
129
136
  onExit={onExit}
137
+ onEnterPreview={onEnterPreview}
130
138
  onDocChanged={() => startTransition(() => router.refresh())}
131
139
  onSessionExpired={leave}
140
+ previewEntryPath={previewEntryPath}
141
+ previewFrame={previewFrame}
132
142
  version={studioPkg.version}
133
143
  />
134
144
  );