@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 +2 -2
- package/src/devbar-client.tsx +280 -0
- package/src/devbar.tsx +14 -276
- package/src/preview-live.tsx +3 -1
- package/src/preview-route.ts +20 -5
- package/src/routes.ts +28 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@backmatter/next",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
}
|
package/src/preview-live.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"use client";
|
|
3
3
|
|
|
4
4
|
import { useEffect, useState, type ReactNode } from "react";
|
|
5
|
-
import type
|
|
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
|
|
package/src/preview-route.ts
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
|
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 =
|
|
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: {
|
|
63
|
-
|
|
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 =
|
|
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);
|