@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 +2 -2
- package/src/devbar-client.tsx +280 -0
- package/src/devbar.tsx +14 -276
- package/src/page-render.ts +44 -43
- package/src/preview-live.tsx +3 -1
- package/src/preview-route.ts +20 -5
- package/src/revalidate.ts +2 -2
- package/src/routes.ts +142 -73
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@backmatter/next",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
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
|
-
|
|
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/page-render.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// packages/next/src/page-render.ts
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
): ReactNode {
|
|
82
|
+
_fallbacks?: BlockFallbacks,
|
|
83
|
+
): Promise<ReactNode> {
|
|
77
84
|
const blocks = asBlocks(doc.frontmatter.blocks);
|
|
78
|
-
const children
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
}
|
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/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 {
|
|
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
|
-
/**
|
|
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: {
|
|
63
|
-
|
|
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 =
|
|
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
|
|
160
|
+
const failFor = options.notFound ?? nextNotFound;
|
|
139
161
|
|
|
140
|
-
|
|
141
|
-
const
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
createElement(
|
|
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 }) => {
|