@backmatter/next 0.0.21 → 0.0.23
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/dist/config.cjs +71 -6
- package/dist/loader.cjs +71 -5
- package/package.json +12 -5
- package/src/cms-proxy.ts +166 -0
- package/src/config.ts +7 -1
- package/src/css.d.ts +2 -0
- package/src/devbar-client.tsx +136 -17
- package/src/devbar.tsx +20 -3
- package/src/gate.ts +85 -0
- package/src/layout-doc.ts +38 -0
- package/src/loader.ts +6 -2
- package/src/page-render.ts +7 -3
- package/src/preview-content.ts +49 -0
- package/src/preview-live.tsx +9 -0
- package/src/preview-refresh.tsx +211 -3
- package/src/preview-route.ts +90 -21
- package/src/render-preview.ts +186 -0
- package/src/request-url.ts +13 -0
- package/src/revalidate.ts +2 -2
- package/src/routes.ts +207 -45
- package/src/site-channel.ts +37 -0
- package/src/site-doc-bridge.tsx +50 -0
- package/src/site-doc.ts +123 -0
- package/src/site-editor.tsx +353 -0
- package/src/source-data.ts +42 -0
- package/src/studio-client.tsx +62 -0
- package/src/studio.tsx +144 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// packages/next/src/site-editor.tsx
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
import { usePathname, useRouter } from "next/navigation";
|
|
5
|
+
import { startTransition, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
6
|
+
import { subscribeSiteDoc } from "./site-doc-bridge";
|
|
7
|
+
import { readSiteDoc, sameSiteDoc, type SiteDocRef } from "./site-doc";
|
|
8
|
+
|
|
9
|
+
function useSiteDoc(): SiteDocRef | null {
|
|
10
|
+
const pathname = usePathname();
|
|
11
|
+
const [doc, setDoc] = useState<SiteDocRef | null>(null);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const apply = (next: SiteDocRef | null) => {
|
|
15
|
+
setDoc((prev) => (sameSiteDoc(prev, next) ? prev : next));
|
|
16
|
+
};
|
|
17
|
+
apply(readSiteDoc());
|
|
18
|
+
const stop = subscribeSiteDoc(apply);
|
|
19
|
+
const observer = new MutationObserver(() => apply(readSiteDoc()));
|
|
20
|
+
observer.observe(document.body, {
|
|
21
|
+
subtree: true,
|
|
22
|
+
childList: true,
|
|
23
|
+
attributes: true,
|
|
24
|
+
attributeFilter: ["data-locale", "data-type", "data-slug", "data-workspace"],
|
|
25
|
+
});
|
|
26
|
+
return () => {
|
|
27
|
+
stop();
|
|
28
|
+
observer.disconnect();
|
|
29
|
+
};
|
|
30
|
+
}, [pathname]);
|
|
31
|
+
|
|
32
|
+
return doc;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Default route the host mounts `@backmatter/studio` on. */
|
|
36
|
+
export const DEFAULT_STUDIO_PATH = "/backmatter";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The panels are frames of the Studio mounted on this same origin. Same
|
|
40
|
+
* origin means the site session cookie is first-party and postMessage needs
|
|
41
|
+
* no foreign origin.
|
|
42
|
+
*/
|
|
43
|
+
function studioBase(studioPath: string): string {
|
|
44
|
+
if (typeof location === "undefined") return "";
|
|
45
|
+
return `${location.origin}${studioPath.replace(/\/+$/, "")}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function SiteChrome({
|
|
49
|
+
branch,
|
|
50
|
+
tree,
|
|
51
|
+
exiting,
|
|
52
|
+
onExit,
|
|
53
|
+
studioPath = DEFAULT_STUDIO_PATH,
|
|
54
|
+
}: {
|
|
55
|
+
branch?: string;
|
|
56
|
+
tree?: string;
|
|
57
|
+
exiting?: boolean;
|
|
58
|
+
onExit: () => void;
|
|
59
|
+
/** Where `@backmatter/studio` is mounted on this site. */
|
|
60
|
+
studioPath?: string;
|
|
61
|
+
}) {
|
|
62
|
+
const router = useRouter();
|
|
63
|
+
const doc = useSiteDoc();
|
|
64
|
+
const [editorMounted, setEditorMounted] = useState(false);
|
|
65
|
+
const [editorOpen, setEditorOpen] = useState(false);
|
|
66
|
+
const [chatMounted, setChatMounted] = useState(false);
|
|
67
|
+
const [chatOpen, setChatOpen] = useState(false);
|
|
68
|
+
const [publishOpen, setPublishOpen] = useState(false);
|
|
69
|
+
const [changeCount, setChangeCount] = useState(0);
|
|
70
|
+
// Set in an effect so SSR and the first client render agree.
|
|
71
|
+
const [base, setBase] = useState("");
|
|
72
|
+
const origin = typeof location === "undefined" ? "" : location.origin;
|
|
73
|
+
const editorFrame = useRef<HTMLIFrameElement>(null);
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
setBase(studioBase(studioPath));
|
|
77
|
+
}, [studioPath]);
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
function onKey(event: KeyboardEvent) {
|
|
81
|
+
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "j") return;
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
setChatMounted(true);
|
|
84
|
+
setChatOpen((open) => !open);
|
|
85
|
+
}
|
|
86
|
+
document.addEventListener("keydown", onKey, true);
|
|
87
|
+
return () => document.removeEventListener("keydown", onKey, true);
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
function onMessage(event: MessageEvent) {
|
|
92
|
+
if (event.origin !== origin) return;
|
|
93
|
+
const data = event.data as {
|
|
94
|
+
type?: string;
|
|
95
|
+
changeCount?: number;
|
|
96
|
+
key?: string;
|
|
97
|
+
metaKey?: boolean;
|
|
98
|
+
ctrlKey?: boolean;
|
|
99
|
+
};
|
|
100
|
+
if (data?.type === "backmatter:git" && typeof data.changeCount === "number") {
|
|
101
|
+
setChangeCount(data.changeCount);
|
|
102
|
+
}
|
|
103
|
+
if (
|
|
104
|
+
data?.type === "backmatter:hotkey" &&
|
|
105
|
+
data.key?.toLowerCase() === "j" &&
|
|
106
|
+
(data.metaKey || data.ctrlKey)
|
|
107
|
+
) {
|
|
108
|
+
setChatMounted(true);
|
|
109
|
+
setChatOpen((open) => !open);
|
|
110
|
+
}
|
|
111
|
+
if (data?.type === "backmatter:published") {
|
|
112
|
+
setPublishOpen(false);
|
|
113
|
+
setChangeCount(0);
|
|
114
|
+
editorFrame.current?.contentWindow?.postMessage({ type: "backmatter:reload-doc" }, origin);
|
|
115
|
+
startTransition(() => router.refresh());
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
window.addEventListener("message", onMessage);
|
|
119
|
+
return () => window.removeEventListener("message", onMessage);
|
|
120
|
+
}, [origin, router]);
|
|
121
|
+
|
|
122
|
+
const label = doc ? `${doc.type}/${doc.slug}` : "No document";
|
|
123
|
+
const editorSrc =
|
|
124
|
+
doc && base
|
|
125
|
+
? `${base}/workspaces/${doc.workspaceId}/docs/${doc.locale}/${doc.type}/${doc.slug}?embed=1`
|
|
126
|
+
: "";
|
|
127
|
+
|
|
128
|
+
function onChatWrote() {
|
|
129
|
+
editorFrame.current?.contentWindow?.postMessage({ type: "backmatter:reload-doc" }, origin);
|
|
130
|
+
startTransition(() => router.refresh());
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<>
|
|
135
|
+
<style>{"body{padding-bottom:36px}"}</style>
|
|
136
|
+
{editorMounted && doc && editorSrc ? (
|
|
137
|
+
<iframe
|
|
138
|
+
ref={editorFrame}
|
|
139
|
+
title={`Edit ${label}`}
|
|
140
|
+
src={editorSrc}
|
|
141
|
+
style={{
|
|
142
|
+
...editorStyle,
|
|
143
|
+
visibility: editorOpen ? "visible" : "hidden",
|
|
144
|
+
pointerEvents: editorOpen ? "auto" : "none",
|
|
145
|
+
}}
|
|
146
|
+
/>
|
|
147
|
+
) : null}
|
|
148
|
+
{chatMounted && doc ? (
|
|
149
|
+
<CmsChat
|
|
150
|
+
doc={doc}
|
|
151
|
+
hidden={!chatOpen}
|
|
152
|
+
onWrote={onChatWrote}
|
|
153
|
+
onClosed={() => setChatOpen(false)}
|
|
154
|
+
base={base}
|
|
155
|
+
/>
|
|
156
|
+
) : null}
|
|
157
|
+
{doc && base ? (
|
|
158
|
+
<iframe
|
|
159
|
+
title="Publish"
|
|
160
|
+
src={`${base}/embed/publish/${doc.workspaceId}`}
|
|
161
|
+
style={{
|
|
162
|
+
...publishStyle,
|
|
163
|
+
visibility: publishOpen ? "visible" : "hidden",
|
|
164
|
+
pointerEvents: publishOpen ? "auto" : "none",
|
|
165
|
+
}}
|
|
166
|
+
/>
|
|
167
|
+
) : null}
|
|
168
|
+
<footer aria-label="Backmatter" style={barStyle}>
|
|
169
|
+
<button
|
|
170
|
+
type="button"
|
|
171
|
+
aria-pressed={editorOpen}
|
|
172
|
+
disabled={!doc}
|
|
173
|
+
onClick={() => {
|
|
174
|
+
setEditorMounted(true);
|
|
175
|
+
setEditorOpen((open) => !open);
|
|
176
|
+
}}
|
|
177
|
+
style={docButton}
|
|
178
|
+
>
|
|
179
|
+
{label}
|
|
180
|
+
</button>
|
|
181
|
+
<BarText>Preview</BarText>
|
|
182
|
+
{branch ? <BarText>{branch}</BarText> : null}
|
|
183
|
+
{tree ? <BarText>draft</BarText> : null}
|
|
184
|
+
<span style={{ flex: 1 }} />
|
|
185
|
+
<button type="button" aria-pressed={publishOpen} onClick={() => setPublishOpen((open) => !open)} style={barButton}>
|
|
186
|
+
Publish{changeCount ? ` ${changeCount}` : ""}
|
|
187
|
+
</button>
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
aria-pressed={chatOpen}
|
|
191
|
+
onClick={() => {
|
|
192
|
+
setChatMounted(true);
|
|
193
|
+
setChatOpen((open) => !open);
|
|
194
|
+
}}
|
|
195
|
+
title="Toggle AI chat (⌘J)"
|
|
196
|
+
style={barButton}
|
|
197
|
+
>
|
|
198
|
+
Agent
|
|
199
|
+
</button>
|
|
200
|
+
<button type="button" aria-label="Exit preview" disabled={exiting} onClick={onExit} style={barButton}>
|
|
201
|
+
Exit
|
|
202
|
+
</button>
|
|
203
|
+
</footer>
|
|
204
|
+
</>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function CmsChat({
|
|
209
|
+
doc,
|
|
210
|
+
hidden,
|
|
211
|
+
onWrote,
|
|
212
|
+
onClosed,
|
|
213
|
+
base,
|
|
214
|
+
}: {
|
|
215
|
+
doc: SiteDocRef;
|
|
216
|
+
hidden: boolean;
|
|
217
|
+
onWrote: () => void;
|
|
218
|
+
onClosed: () => void;
|
|
219
|
+
/** Studio origin plus base path. */
|
|
220
|
+
base: string;
|
|
221
|
+
}) {
|
|
222
|
+
const frame = useRef<HTMLIFrameElement>(null);
|
|
223
|
+
const onWroteRef = useRef(onWrote);
|
|
224
|
+
const onClosedRef = useRef(onClosed);
|
|
225
|
+
const hiddenRef = useRef(hidden);
|
|
226
|
+
onWroteRef.current = onWrote;
|
|
227
|
+
onClosedRef.current = onClosed;
|
|
228
|
+
hiddenRef.current = hidden;
|
|
229
|
+
const origin = base ? new URL(base).origin : "";
|
|
230
|
+
|
|
231
|
+
function openChat() {
|
|
232
|
+
const frameEl = frame.current;
|
|
233
|
+
if (!frameEl) return;
|
|
234
|
+
frameEl.focus();
|
|
235
|
+
frameEl.contentWindow?.postMessage({ type: "backmatter:chat-open" }, origin);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
if (!hidden) openChat();
|
|
240
|
+
}, [hidden, origin]);
|
|
241
|
+
|
|
242
|
+
useEffect(() => {
|
|
243
|
+
const focus = { locale: doc.locale, type: doc.type, slug: doc.slug };
|
|
244
|
+
function postFocus() {
|
|
245
|
+
frame.current?.contentWindow?.postMessage({ type: "backmatter:focus", doc: focus }, origin);
|
|
246
|
+
}
|
|
247
|
+
function onMessage(event: MessageEvent) {
|
|
248
|
+
if (event.origin !== origin) return;
|
|
249
|
+
if (event.data?.type === "backmatter:chat-ready") {
|
|
250
|
+
postFocus();
|
|
251
|
+
if (!hiddenRef.current) openChat();
|
|
252
|
+
}
|
|
253
|
+
if (event.data?.type === "backmatter:doc-changed") onWroteRef.current();
|
|
254
|
+
if (event.data?.type === "backmatter:chat-closed") onClosedRef.current();
|
|
255
|
+
}
|
|
256
|
+
window.addEventListener("message", onMessage);
|
|
257
|
+
postFocus();
|
|
258
|
+
return () => window.removeEventListener("message", onMessage);
|
|
259
|
+
}, [doc.locale, doc.type, doc.slug, origin]);
|
|
260
|
+
|
|
261
|
+
if (!base) return null;
|
|
262
|
+
return (
|
|
263
|
+
<iframe
|
|
264
|
+
ref={frame}
|
|
265
|
+
title="AI chat"
|
|
266
|
+
src={`${base}/embed/chat/${doc.workspaceId}`}
|
|
267
|
+
style={{
|
|
268
|
+
...chatStyle,
|
|
269
|
+
visibility: hidden ? "hidden" : "visible",
|
|
270
|
+
pointerEvents: hidden ? "none" : "auto",
|
|
271
|
+
}}
|
|
272
|
+
/>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function BarText({ children }: { children: ReactNode }) {
|
|
277
|
+
return <span style={mutedStyle}>{children}</span>;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const barStyle: CSSProperties = {
|
|
281
|
+
position: "fixed",
|
|
282
|
+
left: 0,
|
|
283
|
+
right: 0,
|
|
284
|
+
bottom: 0,
|
|
285
|
+
zIndex: 2147483000,
|
|
286
|
+
display: "flex",
|
|
287
|
+
alignItems: "center",
|
|
288
|
+
gap: 12,
|
|
289
|
+
height: 36,
|
|
290
|
+
padding: "0 12px",
|
|
291
|
+
paddingBottom: "env(safe-area-inset-bottom, 0px)",
|
|
292
|
+
boxSizing: "border-box",
|
|
293
|
+
borderTop: "1px solid #e4e4e7",
|
|
294
|
+
background: "#f4f4f5",
|
|
295
|
+
color: "#71717a",
|
|
296
|
+
font: "12px/1 ui-sans-serif, system-ui, sans-serif",
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const barButton: CSSProperties = {
|
|
300
|
+
border: 0,
|
|
301
|
+
background: "transparent",
|
|
302
|
+
color: "#18181b",
|
|
303
|
+
font: "inherit",
|
|
304
|
+
cursor: "pointer",
|
|
305
|
+
padding: "4px 6px",
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const docButton: CSSProperties = {
|
|
309
|
+
...barButton,
|
|
310
|
+
fontWeight: 600,
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const mutedStyle: CSSProperties = { color: "#71717a", fontSize: 12, margin: 0 };
|
|
314
|
+
|
|
315
|
+
const editorStyle: CSSProperties = {
|
|
316
|
+
position: "fixed",
|
|
317
|
+
left: 0,
|
|
318
|
+
right: 0,
|
|
319
|
+
bottom: 36,
|
|
320
|
+
zIndex: 2147482999,
|
|
321
|
+
width: "100%",
|
|
322
|
+
height: "min(78vh, calc(100vh - 48px))",
|
|
323
|
+
border: 0,
|
|
324
|
+
borderTop: "1px solid #e4e4e7",
|
|
325
|
+
background: "#ffffff",
|
|
326
|
+
boxShadow: "0 -8px 24px rgba(24, 24, 27, 0.08)",
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const chatStyle: CSSProperties = {
|
|
330
|
+
position: "fixed",
|
|
331
|
+
right: 12,
|
|
332
|
+
bottom: 48,
|
|
333
|
+
zIndex: 2147483001,
|
|
334
|
+
width: "min(440px, calc(100vw - 24px))",
|
|
335
|
+
height: "min(640px, calc(100vh - 120px))",
|
|
336
|
+
border: 0,
|
|
337
|
+
borderRadius: 14,
|
|
338
|
+
background: "#f4f4f5",
|
|
339
|
+
boxShadow: "0 16px 48px rgba(0,0,0,0.22), 0 2px 8px rgba(0,0,0,0.08)",
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const publishStyle: CSSProperties = {
|
|
343
|
+
position: "fixed",
|
|
344
|
+
right: 12,
|
|
345
|
+
bottom: 48,
|
|
346
|
+
zIndex: 2147483001,
|
|
347
|
+
width: "min(420px, calc(100vw - 24px))",
|
|
348
|
+
height: "min(520px, calc(100vh - 120px))",
|
|
349
|
+
border: "1px solid #e4e4e7",
|
|
350
|
+
borderRadius: 8,
|
|
351
|
+
background: "#ffffff",
|
|
352
|
+
boxShadow: "0 12px 40px rgba(24, 24, 27, 0.16)",
|
|
353
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// packages/next/src/source-data.ts
|
|
2
|
+
// The one place that decides how an external model's record is obtained for a
|
|
3
|
+
// route: `load(ref)` for a real slug, `sample()` for the template document.
|
|
4
|
+
// Both the catch-all and `renderPreview` call this; `renderLayoutDoc` only
|
|
5
|
+
// receives the result.
|
|
6
|
+
import { isTemplateSlug, type ModelDef, type SourceDef, type SourceRef } from "@backmatter/core";
|
|
7
|
+
|
|
8
|
+
export type SourceResult = { ok: true; data: unknown } | { ok: false };
|
|
9
|
+
|
|
10
|
+
/** Source for `model`, or throw when an external model has no `sources/{type}.ts`. */
|
|
11
|
+
export function requireSource(
|
|
12
|
+
sources: Record<string, SourceDef> | undefined,
|
|
13
|
+
model: ModelDef,
|
|
14
|
+
): SourceDef | undefined {
|
|
15
|
+
const source = sources?.[model.type];
|
|
16
|
+
if (model.external && !source) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
`[backmatter] model "${model.type}" is external but sources/${model.type}.ts was not found`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
return source;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `{ ok: false }` means the outside record does not exist and the route is a 404.
|
|
26
|
+
* Without a source `data` is `undefined`. On the template slug `data` is the
|
|
27
|
+
* sample record, or `null` when the source has no `sample`.
|
|
28
|
+
*/
|
|
29
|
+
export async function resolveSourceData(
|
|
30
|
+
source: SourceDef | undefined,
|
|
31
|
+
model: ModelDef,
|
|
32
|
+
ref: SourceRef,
|
|
33
|
+
): Promise<SourceResult> {
|
|
34
|
+
if (!source) return { ok: true, data: undefined };
|
|
35
|
+
if (isTemplateSlug(model, ref.slug)) {
|
|
36
|
+
const sample = source.sample ? await source.sample(ref.locale) : null;
|
|
37
|
+
return { ok: true, data: sample };
|
|
38
|
+
}
|
|
39
|
+
const record = await source.load(ref);
|
|
40
|
+
if (record === null || record === undefined) return { ok: false };
|
|
41
|
+
return { ok: true, data: record };
|
|
42
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// packages/next/src/studio-client.tsx
|
|
3
|
+
// Loads @backmatter/studio in the browser only. The Studio is a client app
|
|
4
|
+
// (react-router, IndexedDB replica, StyleX output); it has no server render.
|
|
5
|
+
import { useEffect, useState, type ComponentType } from "react";
|
|
6
|
+
import type { StudioDefinition } from "@backmatter/studio/define";
|
|
7
|
+
import "@backmatter/studio/studio.css";
|
|
8
|
+
|
|
9
|
+
export type StudioMountProps = {
|
|
10
|
+
workspaceId: string;
|
|
11
|
+
basePath: string;
|
|
12
|
+
dashboardUrl: string;
|
|
13
|
+
version: string;
|
|
14
|
+
proxyPath: string;
|
|
15
|
+
previewEntryPath: string;
|
|
16
|
+
/** CMS API origin for display (Content API URLs); requests use the proxy. */
|
|
17
|
+
apiUrl?: string;
|
|
18
|
+
/**
|
|
19
|
+
* `defineStudio(...)` from a `"use client"` module. The server page passes
|
|
20
|
+
* the client reference; it resolves to the object here.
|
|
21
|
+
*/
|
|
22
|
+
studio?: StudioDefinition;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type SiteStudioComponent = ComponentType<StudioMountProps>;
|
|
26
|
+
|
|
27
|
+
export function StudioMount(props: StudioMountProps) {
|
|
28
|
+
const [Studio, setStudio] = useState<SiteStudioComponent | null>(null);
|
|
29
|
+
const [error, setError] = useState<string | null>(null);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
let cancelled = false;
|
|
33
|
+
import("@backmatter/studio")
|
|
34
|
+
.then((mod) => {
|
|
35
|
+
if (!cancelled) setStudio(() => mod.SiteStudio as SiteStudioComponent);
|
|
36
|
+
})
|
|
37
|
+
.catch((err: unknown) => {
|
|
38
|
+
if (!cancelled) setError(err instanceof Error ? err.message : "Could not load the Studio");
|
|
39
|
+
});
|
|
40
|
+
return () => {
|
|
41
|
+
cancelled = true;
|
|
42
|
+
};
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
if (error) {
|
|
46
|
+
return (
|
|
47
|
+
<div style={fallbackStyle} role="alert">
|
|
48
|
+
{error}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (!Studio) return <div style={fallbackStyle} aria-busy="true" />;
|
|
53
|
+
return <Studio {...props} />;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const fallbackStyle = {
|
|
57
|
+
minHeight: "100dvh",
|
|
58
|
+
display: "grid",
|
|
59
|
+
placeItems: "center",
|
|
60
|
+
fontFamily: "ui-sans-serif, system-ui, sans-serif",
|
|
61
|
+
color: "#71717a",
|
|
62
|
+
} as const;
|
package/src/studio.tsx
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// packages/next/src/studio.tsx
|
|
2
|
+
// Mount the Backmatter Studio on a route of the site.
|
|
3
|
+
//
|
|
4
|
+
// // app/(backmatter)/backmatter/layout.tsx
|
|
5
|
+
// export { StudioLayout as default } from "@backmatter/next/studio";
|
|
6
|
+
//
|
|
7
|
+
// // app/(backmatter)/backmatter/[[...path]]/page.tsx
|
|
8
|
+
// import { createStudioPage } from "@backmatter/next/studio";
|
|
9
|
+
// import { cms } from "@/.backmatter";
|
|
10
|
+
// export default createStudioPage(cms);
|
|
11
|
+
//
|
|
12
|
+
// The Studio talks to `/api/backmatter/cms/*` on this origin with the site
|
|
13
|
+
// session cookie. Without a session the page redirects top-level to the
|
|
14
|
+
// dashboard `enter` route, which mints a ticket and returns here.
|
|
15
|
+
import { Suspense, type ReactNode } from "react";
|
|
16
|
+
import {
|
|
17
|
+
PREVIEW_SESSION_COOKIE,
|
|
18
|
+
readPreviewSession,
|
|
19
|
+
resolveDashboardUrl,
|
|
20
|
+
studioEnterUrl,
|
|
21
|
+
} from "@backmatter/core";
|
|
22
|
+
import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
|
|
23
|
+
import studioPkg from "@backmatter/studio/package.json";
|
|
24
|
+
import type { StudioDefinition } from "@backmatter/studio/define";
|
|
25
|
+
import { requestUrlFrom } from "./request-url";
|
|
26
|
+
import { StudioMount } from "./studio-client";
|
|
27
|
+
|
|
28
|
+
export { requestUrlFrom };
|
|
29
|
+
|
|
30
|
+
export type StudioPageOptions = {
|
|
31
|
+
/** Where the Studio is mounted. Default `/backmatter`. */
|
|
32
|
+
basePath?: string;
|
|
33
|
+
/** Dashboard that holds the login. Default: `resolveDashboardUrl()`. */
|
|
34
|
+
dashboardUrl?: string;
|
|
35
|
+
/** Workspace to open. Default: `cms.config.workspaceId`. */
|
|
36
|
+
workspaceId?: string;
|
|
37
|
+
/** Same-origin proxy route. Default `/api/backmatter/cms`. */
|
|
38
|
+
proxyPath?: string;
|
|
39
|
+
/** Ticket entry route. Default `/api/backmatter/preview`. */
|
|
40
|
+
previewEntryPath?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Site customization: the default export of a `"use client"` module that
|
|
43
|
+
* calls `defineStudio`. The server only forwards the reference.
|
|
44
|
+
*/
|
|
45
|
+
studio?: StudioDefinition;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type NextHeaders = {
|
|
49
|
+
cookies: () => Promise<{ get: (name: string) => { value: string } | undefined }>;
|
|
50
|
+
headers: () => Promise<{ get: (name: string) => string | null }>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type NextNavigation = { redirect: (url: string) => never };
|
|
54
|
+
|
|
55
|
+
async function nextHeaders(): Promise<NextHeaders> {
|
|
56
|
+
return (await import("next/headers" as string)) as NextHeaders;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function nextNavigation(): Promise<NextNavigation> {
|
|
60
|
+
return (await import("next/navigation" as string)) as NextNavigation;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function studioPathFor(basePath: string, segments: string[] | undefined, search = ""): string {
|
|
64
|
+
const rest = segments && segments.length > 0 ? `/${segments.map(encodeURIComponent).join("/")}` : "";
|
|
65
|
+
return `${basePath}${rest}${search}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createStudioPage(
|
|
69
|
+
cms: Pick<BackmatterClient, "config">,
|
|
70
|
+
options: StudioPageOptions = {},
|
|
71
|
+
) {
|
|
72
|
+
const basePath = (options.basePath ?? "/backmatter").replace(/\/+$/, "") || "/backmatter";
|
|
73
|
+
const proxyPath = options.proxyPath ?? "/api/backmatter/cms";
|
|
74
|
+
const previewEntryPath = options.previewEntryPath ?? "/api/backmatter/preview";
|
|
75
|
+
|
|
76
|
+
return async function StudioPage(props: {
|
|
77
|
+
params: Promise<{ path?: string[] }> | { path?: string[] };
|
|
78
|
+
searchParams?: Promise<Record<string, string | string[] | undefined>> | Record<string, string | string[] | undefined>;
|
|
79
|
+
}) {
|
|
80
|
+
const workspaceId = options.workspaceId ?? cms.config.workspaceId;
|
|
81
|
+
if (!workspaceId) {
|
|
82
|
+
throw new Error("backmatter: the Studio needs a workspaceId (backmatter.config.ts or BACKMATTER_WORKSPACE_ID)");
|
|
83
|
+
}
|
|
84
|
+
const dashboardUrl = resolveDashboardUrl(options.dashboardUrl);
|
|
85
|
+
const { cookies, headers } = await nextHeaders();
|
|
86
|
+
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
|
|
87
|
+
|
|
88
|
+
const token = cookieStore.get(PREVIEW_SESSION_COOKIE)?.value;
|
|
89
|
+
const secret = cms.config.previewToken;
|
|
90
|
+
const claims = token && secret ? await readPreviewSession(secret, token) : null;
|
|
91
|
+
const valid = claims !== null && claims.workspaceId === workspaceId;
|
|
92
|
+
|
|
93
|
+
if (!valid) {
|
|
94
|
+
const params = await props.params;
|
|
95
|
+
const search = props.searchParams ? await props.searchParams : {};
|
|
96
|
+
const qs = new URLSearchParams();
|
|
97
|
+
for (const [key, value] of Object.entries(search)) {
|
|
98
|
+
if (typeof value === "string") qs.set(key, value);
|
|
99
|
+
}
|
|
100
|
+
const searchText = qs.size > 0 ? `?${qs.toString()}` : "";
|
|
101
|
+
const returnUrl = requestUrlFrom(
|
|
102
|
+
(name) => headerStore.get(name),
|
|
103
|
+
studioPathFor(basePath, params.path, searchText),
|
|
104
|
+
);
|
|
105
|
+
const { redirect } = await nextNavigation();
|
|
106
|
+
redirect(studioEnterUrl(dashboardUrl, { workspaceId, returnUrl }));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<StudioMount
|
|
111
|
+
workspaceId={workspaceId}
|
|
112
|
+
basePath={basePath}
|
|
113
|
+
dashboardUrl={dashboardUrl}
|
|
114
|
+
version={studioPkg.version}
|
|
115
|
+
proxyPath={proxyPath}
|
|
116
|
+
previewEntryPath={previewEntryPath}
|
|
117
|
+
apiUrl={cms.config.apiUrl}
|
|
118
|
+
studio={options.studio}
|
|
119
|
+
/>
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Root layout for the Studio route group. Its own `<html>` so the site's
|
|
126
|
+
* layout, fonts and global CSS do not wrap the editor.
|
|
127
|
+
*/
|
|
128
|
+
export function StudioLayout({ children }: { children: ReactNode }) {
|
|
129
|
+
return (
|
|
130
|
+
<html lang="en" suppressHydrationWarning>
|
|
131
|
+
<head>
|
|
132
|
+
<meta name="robots" content="noindex, nofollow" />
|
|
133
|
+
<script dangerouslySetInnerHTML={{ __html: THEME_BOOT }} />
|
|
134
|
+
</head>
|
|
135
|
+
<body style={{ margin: 0 }}>
|
|
136
|
+
{/* The page reads cookies; with Cache Components that must sit under Suspense. */}
|
|
137
|
+
<Suspense fallback={null}>{children}</Suspense>
|
|
138
|
+
</body>
|
|
139
|
+
</html>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Same first-paint theme pick as the dashboard's index.html.
|
|
144
|
+
const THEME_BOOT = `(function(){try{var s=localStorage.getItem("backmatter-theme");var m=s==="light"||s==="dark"?s:"system";var d=m==="dark"||(m!=="light"&&window.matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.dataset.theme=d?"dark":"light";document.documentElement.style.colorScheme=d?"dark":"light"}catch(e){}})();`;
|