@backmatter/next 0.0.6 → 0.0.9

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.9",
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.9",
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
  }
@@ -1,5 +1,5 @@
1
1
  // packages/next/src/page-render.ts
2
- import { Suspense, createElement, type ComponentType, type ReactNode } from "react";
2
+ import { createElement, type ComponentType, type ReactNode } from "react";
3
3
  import { imageUrlFromUnknown, type BackmatterDoc } from "@backmatter/core";
4
4
 
5
5
  export type BlockModule = Record<string, unknown>;
@@ -50,17 +50,24 @@ export function asBlockLoader(type: string, value: ComponentType<any> | BlockLoa
50
50
 
51
51
  const resolvedBlocks = new WeakMap<BlockLoader, Promise<ComponentType<any> | undefined>>();
52
52
 
53
+ async function loadBlockComponent(
54
+ type: string,
55
+ loader: BlockLoader,
56
+ ): Promise<ComponentType<any> | undefined> {
57
+ let pending = resolvedBlocks.get(loader);
58
+ if (!pending) {
59
+ pending = loader().then((mod) => resolveBlockComponent(mod, type));
60
+ resolvedBlocks.set(loader, pending);
61
+ }
62
+ return pending;
63
+ }
64
+
53
65
  export async function LazyBlock(props: {
54
66
  type: string;
55
67
  loader: BlockLoader;
56
68
  blockProps: Record<string, unknown>;
57
69
  }) {
58
- let pending = resolvedBlocks.get(props.loader);
59
- if (!pending) {
60
- pending = props.loader().then((mod) => resolveBlockComponent(mod, props.type));
61
- resolvedBlocks.set(props.loader, pending);
62
- }
63
- const Cmp = await pending;
70
+ const Cmp = await loadBlockComponent(props.type, props.loader);
64
71
  if (!Cmp) {
65
72
  console.warn(`[backmatter] skipping unknown block type "${props.type}"`);
66
73
  return null;
@@ -69,43 +76,37 @@ export async function LazyBlock(props: {
69
76
  }
70
77
 
71
78
  /** Render a page document's `blocks` array. Unknown types are skipped. */
72
- export function renderPage(
79
+ export async function renderPage(
73
80
  doc: Pick<BackmatterDoc, "frontmatter">,
74
81
  components?: BlockComponents,
75
- fallbacks?: BlockFallbacks,
76
- ): ReactNode {
82
+ _fallbacks?: BlockFallbacks,
83
+ ): Promise<ReactNode> {
77
84
  const blocks = asBlocks(doc.frontmatter.blocks);
78
- const children: ReactNode[] = [];
79
- for (const block of blocks) {
80
- if (typeof block.type !== "string") continue;
81
- const entry = components?.[block.type];
82
- if (!entry) {
83
- console.warn(`[backmatter] skipping unknown block type "${block.type}"`);
84
- continue;
85
- }
86
- const raw =
87
- block.props && typeof block.props === "object" && !Array.isArray(block.props)
88
- ? (block.props as Record<string, unknown>)
89
- : {};
90
- const props: Record<string, unknown> = {};
91
- for (const [name, value] of Object.entries(raw)) {
92
- props[name] = imageUrlFromUnknown(value);
93
- }
94
- const key = typeof block.id === "string" ? block.id : undefined;
95
- children.push(
96
- createElement(
97
- Suspense,
98
- {
99
- key,
100
- fallback: createElement(fallbacks?.[block.type] ?? BlockSkeleton),
101
- },
102
- createElement(LazyBlock, {
103
- type: block.type,
104
- loader: asBlockLoader(block.type, entry),
105
- blockProps: props,
106
- }),
107
- ),
108
- );
109
- }
110
- return children.length === 0 ? null : children;
85
+ const children = await Promise.all(
86
+ blocks.map(async (block) => {
87
+ if (typeof block.type !== "string") return null;
88
+ const entry = components?.[block.type];
89
+ if (!entry) {
90
+ console.warn(`[backmatter] skipping unknown block type "${block.type}"`);
91
+ return null;
92
+ }
93
+ const raw =
94
+ block.props && typeof block.props === "object" && !Array.isArray(block.props)
95
+ ? (block.props as Record<string, unknown>)
96
+ : {};
97
+ const props: Record<string, unknown> = {};
98
+ for (const [name, value] of Object.entries(raw)) {
99
+ props[name] = imageUrlFromUnknown(value);
100
+ }
101
+ const Cmp = await loadBlockComponent(block.type, asBlockLoader(block.type, entry));
102
+ if (!Cmp) {
103
+ console.warn(`[backmatter] skipping unknown block type "${block.type}"`);
104
+ return null;
105
+ }
106
+ const key = typeof block.id === "string" ? block.id : undefined;
107
+ return createElement(Cmp, { key, ...props });
108
+ }),
109
+ );
110
+ const rendered = children.filter((child) => child != null);
111
+ return rendered.length === 0 ? null : rendered;
111
112
  }
@@ -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/revalidate.ts CHANGED
@@ -12,7 +12,7 @@ import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
12
12
 
13
13
  export type RevalidateCache = {
14
14
  revalidatePath: (path: string, type?: "layout" | "page") => void;
15
- revalidateTag: (tag: string) => void;
15
+ revalidateTag: (tag: string, profile?: string | { expire?: number }) => void;
16
16
  };
17
17
 
18
18
  function extractBearer(header: string | null): string | null {
@@ -57,7 +57,7 @@ export function createRevalidateRoute(
57
57
  const { revalidatePath, revalidateTag } = cache ?? (await loadNextCache());
58
58
  const paths = pathsForCommittedDocs(cms.models, body.docs ?? []);
59
59
  for (const path of paths) revalidatePath(path);
60
- revalidateTag(BACKMATTER_CACHE_TAG);
60
+ revalidateTag(BACKMATTER_CACHE_TAG, { expire: 0 });
61
61
  if (body.full) revalidatePath("/", "layout");
62
62
  return Response.json({ ok: true, paths });
63
63
  };
package/src/routes.ts CHANGED
@@ -3,15 +3,13 @@
3
3
  // app/[[...backmatter]]/page.tsx. It maps the URL to a document via each model's
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
- import { Suspense, createElement, type ReactNode } from "react";
7
- import { matchRoute, modelFormat, PREVIEW_COOKIE, readPreviewCookie, routeSegments, type BackmatterDoc, type ModelDef } from "@backmatter/core";
6
+ import { Fragment, Suspense, createElement, type ReactNode } from "react";
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";
11
11
  import { getCachedDoc, registerCachedDocGet } from "./cached-doc";
12
- import { BlockSkeleton, renderPage, type BlockComponents, type BlockFallbacks } from "./page-render";
13
- import { PreviewBody } from "./preview-live";
14
- import { PreviewPageReloader } from "./preview-refresh";
12
+ import { renderPage, type BlockComponents, type BlockFallbacks } from "./page-render";
15
13
  import { previewUnavailable } from "./preview-ui";
16
14
 
17
15
  export const CATCH_ALL_PARAM = "backmatter";
@@ -25,7 +23,7 @@ export type CatchAllOptions = {
25
23
  components?: MdxComponents;
26
24
  /** Composer map, usually the generated `blocks`. */
27
25
  blocks?: BlockComponents;
28
- /** Per-block Suspense fallbacks for page-format documents. */
26
+ /** Kept for host compatibility. Published pages resolve blocks before paint. */
29
27
  fallbacks?: BlockFallbacks;
30
28
  /** Per-type wrappers, usually the generated `layouts`. */
31
29
  layouts?: Record<string, DocLayout>;
@@ -58,9 +56,17 @@ export type CatchAllOptions = {
58
56
 
59
57
  export type CatchAllMetadata = { title?: string };
60
58
 
59
+ export type CatchAllSearchParams = Record<string, string | string[] | undefined>;
60
+
61
61
  export type CatchAllRoute = {
62
- Page: (props: { params: Promise<CatchAllParams> }) => ReactNode;
63
- CmsRoute: (props: { params: Promise<CatchAllParams> | CatchAllParams }) => Promise<ReactNode>;
62
+ Page: (props: {
63
+ params: Promise<CatchAllParams>;
64
+ searchParams?: Promise<CatchAllSearchParams>;
65
+ }) => ReactNode;
66
+ CmsRoute: (props: {
67
+ params: Promise<CatchAllParams> | CatchAllParams;
68
+ searchParams?: Promise<CatchAllSearchParams> | CatchAllSearchParams;
69
+ }) => Promise<ReactNode>;
64
70
  generateStaticParams: () => Promise<CatchAllParams[]>;
65
71
  generateMetadata: (props: {
66
72
  params: Promise<CatchAllParams>;
@@ -81,9 +87,23 @@ type PreviewState =
81
87
  | { enabled: false }
82
88
  | { enabled: true; checkout: { branch?: string; tree?: string }; error?: string };
83
89
 
90
+ type RouteHit = { locale: string; type: string; slug: string };
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 {
@@ -135,81 +157,128 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
135
157
  return params;
136
158
  };
137
159
 
138
- const PageFallback = BlockSkeleton;
160
+ const failFor = options.notFound ?? nextNotFound;
139
161
 
140
- const CmsRoute: CatchAllRoute["CmsRoute"] = async ({ params }) => {
141
- const resolved = await params;
142
- const hit = resolve(resolved[CATCH_ALL_PARAM]);
143
- const fail = options.notFound ?? nextNotFound;
144
- if (!hit) return fail();
145
- const preview = await resolvePreviewState(cms, options.preview);
146
- let doc: BackmatterDoc | null;
147
- if (preview.enabled) {
148
- if (preview.error) return previewUnavailable(preview.error);
149
- try {
150
- const getDoc =
151
- options.preview?.getDoc ??
152
- ((ref) =>
153
- fetchPreviewDoc(
154
- {
155
- apiUrl: cms.config.apiUrl ?? "",
156
- workspaceId: cms.config.workspaceId ?? "",
157
- previewToken: cms.config.previewToken ?? "",
158
- },
159
- ref,
160
- ));
161
- doc = await getDoc({
162
- locale: hit.locale,
163
- type: hit.type,
164
- slug: hit.slug,
165
- ...preview.checkout,
166
- });
167
- } catch (err) {
168
- return previewUnavailable(err instanceof Error ? err.message : "Failed to load");
169
- }
170
- } else {
171
- doc = await getCachedDoc(hit.locale, hit.type, hit.slug);
172
- }
173
- if (!doc) return fail();
174
- const model = byType.get(hit.type);
162
+ async function layoutDoc(doc: BackmatterDoc, previewEnabled: boolean): Promise<ReactNode> {
163
+ const model = byType.get(doc.type);
175
164
  const isPage = Boolean(model && modelFormat(model) === "page");
176
165
  const rendered = isPage
177
- ? renderPage(doc, options.blocks, options.fallbacks)
166
+ ? await renderPage(doc, options.blocks, options.fallbacks)
178
167
  : await renderDoc(doc, { components: options.components });
179
- const body = isPage
180
- ? rendered
181
- : preview.enabled
182
- ? createElement(
183
- PreviewBody,
184
- { doc, components: options.components },
185
- rendered,
186
- )
187
- : rendered;
188
- const withLive =
189
- preview.enabled && isPage
190
- ? createElement(
191
- "div",
192
- null,
193
- createElement(PreviewPageReloader, {
194
- locale: doc.locale,
195
- type: doc.type,
196
- slug: doc.slug,
197
- }),
198
- body,
199
- )
200
- : body;
201
- const layout = options.layouts?.[hit.type];
202
- const laidOut = layout ? layout(doc, withLive) : withLive;
168
+ let body = rendered;
169
+ if (!isPage && previewEnabled) {
170
+ const { PreviewBody } = await import("./preview-live");
171
+ body = createElement(PreviewBody, { doc, components: options.components }, rendered);
172
+ }
173
+ if (previewEnabled && isPage) {
174
+ const { PreviewPageReloader } = await import("./preview-refresh");
175
+ body = createElement(
176
+ "div",
177
+ null,
178
+ createElement(PreviewPageReloader, {
179
+ locale: doc.locale,
180
+ type: doc.type,
181
+ slug: doc.slug,
182
+ }),
183
+ body,
184
+ );
185
+ }
186
+ const layout = options.layouts?.[doc.type];
187
+ const laidOut = layout ? layout(doc, body) : body;
203
188
  if (options.render) return options.render(doc, laidOut);
204
189
  if (layout || isPage) return laidOut;
205
190
  return createElement("article", null, laidOut);
191
+ }
192
+
193
+ async function loadPreviewDoc(
194
+ hit: RouteHit,
195
+ preview: Extract<PreviewState, { enabled: true }>,
196
+ ): Promise<ReactNode> {
197
+ if (preview.error) return previewUnavailable(preview.error);
198
+ try {
199
+ const getDoc =
200
+ options.preview?.getDoc ??
201
+ ((ref) =>
202
+ fetchPreviewDoc(
203
+ {
204
+ apiUrl: cms.config.apiUrl ?? "",
205
+ workspaceId: cms.config.workspaceId ?? "",
206
+ previewToken: cms.config.previewToken ?? "",
207
+ },
208
+ ref,
209
+ ));
210
+ const doc = await getDoc({
211
+ locale: hit.locale,
212
+ type: hit.type,
213
+ slug: hit.slug,
214
+ ...preview.checkout,
215
+ });
216
+ if (!doc) return previewUnavailable("Document not found");
217
+ return layoutDoc(doc, true);
218
+ } catch (err) {
219
+ return previewUnavailable(err instanceof Error ? err.message : "Failed to load");
220
+ }
221
+ }
222
+
223
+ const CmsRoute: CatchAllRoute["CmsRoute"] = async ({ params, searchParams }) => {
224
+ const resolved = await params;
225
+ const hit = resolve(resolved[CATCH_ALL_PARAM]);
226
+ if (!hit) return failFor();
227
+ const preview = await resolvePreviewState(cms, options.preview, searchParams);
228
+ if (preview.enabled) return loadPreviewDoc(hit, preview);
229
+ const doc = await getCachedDoc(hit.locale, hit.type, hit.slug);
230
+ if (!doc) return failFor();
231
+ return layoutDoc(doc, false);
232
+ };
233
+
234
+ const PublishedRoute = async ({
235
+ params,
236
+ }: {
237
+ params: Promise<CatchAllParams> | CatchAllParams;
238
+ searchParams?: Promise<CatchAllSearchParams> | CatchAllSearchParams;
239
+ }) => {
240
+ const resolved = await params;
241
+ const hit = resolve(resolved[CATCH_ALL_PARAM]);
242
+ if (!hit) return failFor();
243
+ const doc = await getCachedDoc(hit.locale, hit.type, hit.slug);
244
+ if (!doc) return failFor();
245
+ return createElement(
246
+ "div",
247
+ { "data-backmatter-published": "", style: { display: "contents" } },
248
+ await layoutDoc(doc, false),
249
+ );
250
+ };
251
+
252
+ const PreviewBranch = async ({
253
+ params,
254
+ searchParams,
255
+ }: {
256
+ params: Promise<CatchAllParams> | CatchAllParams;
257
+ searchParams?: Promise<CatchAllSearchParams> | CatchAllSearchParams;
258
+ }) => {
259
+ const resolved = await params;
260
+ const hit = resolve(resolved[CATCH_ALL_PARAM]);
261
+ if (!hit) return null;
262
+ const preview = await resolvePreviewState(cms, options.preview, searchParams);
263
+ if (!preview.enabled) return null;
264
+ return createElement(
265
+ Fragment,
266
+ null,
267
+ createElement("style", {
268
+ dangerouslySetInnerHTML: {
269
+ __html: "[data-backmatter-published]{display:none!important}",
270
+ },
271
+ }),
272
+ await loadPreviewDoc(hit, preview),
273
+ );
206
274
  };
207
275
 
208
276
  const Page: CatchAllRoute["Page"] = (props) =>
209
277
  createElement(
210
- Suspense,
211
- { fallback: createElement(PageFallback) },
212
- createElement(CmsRoute, props),
278
+ Fragment,
279
+ null,
280
+ createElement(PublishedRoute, props),
281
+ createElement(Suspense, { fallback: null }, createElement(PreviewBranch, props)),
213
282
  );
214
283
 
215
284
  const generateMetadata: CatchAllRoute["generateMetadata"] = async ({ params }) => {