@backmatter/next 0.0.26 → 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.26",
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.4"
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
  }
@@ -0,0 +1,39 @@
1
+ // packages/next/src/preview-hold.tsx
2
+ "use client";
3
+
4
+ // A route refresh suspends the preview branch. Its Suspense fallback would
5
+ // otherwise be empty for the whole fetch. The last painted preview is kept
6
+ // here, outside that branch, and shown until the next one is ready.
7
+ import { useEffect, useSyncExternalStore, type ReactNode } from "react";
8
+
9
+ let held: ReactNode = null;
10
+ const listeners = new Set<() => void>();
11
+
12
+ function emit() {
13
+ for (const listener of listeners) listener();
14
+ }
15
+
16
+ function subscribe(listener: () => void) {
17
+ listeners.add(listener);
18
+ return () => listeners.delete(listener);
19
+ }
20
+
21
+ export function clearHeldPreview() {
22
+ if (held === null) return;
23
+ held = null;
24
+ emit();
25
+ }
26
+
27
+ /** Remember the preview that just painted, so a later refresh can keep it. */
28
+ export function HoldPreview({ children }: { children: ReactNode }) {
29
+ useEffect(() => {
30
+ held = children;
31
+ emit();
32
+ }, [children]);
33
+ return children;
34
+ }
35
+
36
+ /** Suspense fallback: the previous preview, or nothing on the first load. */
37
+ export function HeldPreview() {
38
+ return useSyncExternalStore(subscribe, () => held, () => null);
39
+ }
@@ -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, ""));
package/src/routes.ts CHANGED
@@ -4,6 +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 { Fragment, Suspense, createElement, type ReactNode } from "react";
7
+ import { HeldPreview, HoldPreview } from "./preview-hold";
7
8
  import {
8
9
  DEFAULT_TEMPLATE_SLUG,
9
10
  isRoutable,
@@ -425,15 +426,19 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
425
426
  const preview = await resolvePreviewState(cms, options.preview, searchParams);
426
427
  if (!previewReplacesPublished(preview)) return null;
427
428
  return createElement(
428
- Fragment,
429
+ HoldPreview,
429
430
  null,
430
- createElement("style", {
431
- dangerouslySetInnerHTML: {
432
- __html: "[data-backmatter-published]{display:none!important}",
433
- },
434
- }),
435
- docBridge(hit),
436
- await loadPreviewDoc(hit, preview),
431
+ createElement(
432
+ Fragment,
433
+ null,
434
+ createElement("style", {
435
+ dangerouslySetInnerHTML: {
436
+ __html: "[data-backmatter-published]{display:none!important}",
437
+ },
438
+ }),
439
+ docBridge(hit),
440
+ await loadPreviewDoc(hit, preview),
441
+ ),
437
442
  );
438
443
  };
439
444
 
@@ -454,7 +459,7 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
454
459
  Fragment,
455
460
  null,
456
461
  createElement(PublishedRoute, props),
457
- createElement(Suspense, { fallback: null }, createElement(PreviewBranch, props)),
462
+ createElement(Suspense, { fallback: createElement(HeldPreview) }, createElement(PreviewBranch, props)),
458
463
  );
459
464
 
460
465
  const generateMetadata: CatchAllRoute["generateMetadata"] = async ({ params }) => {
@@ -9,6 +9,7 @@ import "@backmatter/studio/studio.css";
9
9
  import studioPkg from "@backmatter/studio/package.json";
10
10
  import { SiteWidget } from "@backmatter/studio/widget";
11
11
  import { markerCookie } from "./gate";
12
+ import { clearHeldPreview } from "./preview-hold";
12
13
  import { subscribeSiteDoc } from "./site-doc-bridge";
13
14
  import { readSiteDoc, sameSiteDoc, type SiteDocRef } from "./site-doc";
14
15
 
@@ -19,10 +20,23 @@ const SESSION_RENEW_MS = 30 * 60 * 1000;
19
20
  function useSiteDoc(): SiteDocRef | null {
20
21
  const pathname = usePathname();
21
22
  const [doc, setDoc] = useState<SiteDocRef | null>(null);
23
+ const pathRef = useRef(pathname);
24
+
25
+ useEffect(() => {
26
+ if (pathRef.current === pathname) return;
27
+ pathRef.current = pathname;
28
+ clearHeldPreview();
29
+ setDoc(null);
30
+ }, [pathname]);
22
31
 
23
32
  useEffect(() => {
24
33
  const apply = (next: SiteDocRef | null) => {
25
- setDoc((prev) => (sameSiteDoc(prev, next) ? prev : next));
34
+ setDoc((prev) => {
35
+ // A refresh unmounts the doc marker for a moment. Keep the document
36
+ // the bar already has so the chat stays mounted across that gap.
37
+ if (!next) return prev;
38
+ return sameSiteDoc(prev, next) ? prev : next;
39
+ });
26
40
  };
27
41
  apply(readSiteDoc());
28
42
  const stop = subscribeSiteDoc(apply);
@@ -48,18 +62,24 @@ export const DEFAULT_STUDIO_PATH = "/backmatter";
48
62
  export function SiteChrome({
49
63
  branch,
50
64
  tree,
65
+ siteMode = "preview",
51
66
  exiting,
52
67
  onExit,
68
+ onEnterPreview,
53
69
  previewEntryPath = DEFAULT_PREVIEW_ENTRY_PATH,
70
+ previewFrame,
54
71
  }: {
55
72
  branch?: string;
56
73
  tree?: string;
74
+ siteMode?: "preview" | "live";
57
75
  exiting?: boolean;
58
76
  onExit: () => void;
77
+ onEnterPreview?: () => void;
59
78
  /** Kept so existing hosts can still pass it. The bar does not open the document editor. */
60
79
  studioPath?: string;
61
80
  /** Ticket route. `POST ?renew=1` slides the site session. A 401 drops the bar. */
62
81
  previewEntryPath?: string;
82
+ previewFrame?: boolean;
63
83
  }) {
64
84
  const router = useRouter();
65
85
  const doc = useSiteDoc();
@@ -111,10 +131,14 @@ export function SiteChrome({
111
131
  doc={doc}
112
132
  branch={branch}
113
133
  tree={tree}
134
+ siteMode={siteMode}
114
135
  exiting={exiting}
115
136
  onExit={onExit}
137
+ onEnterPreview={onEnterPreview}
116
138
  onDocChanged={() => startTransition(() => router.refresh())}
117
139
  onSessionExpired={leave}
140
+ previewEntryPath={previewEntryPath}
141
+ previewFrame={previewFrame}
118
142
  version={studioPkg.version}
119
143
  />
120
144
  );