@backmatter/next 0.0.22 → 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/package.json +9 -5
- package/src/cms-proxy.ts +69 -11
- package/src/css.d.ts +2 -0
- package/src/devbar-client.tsx +110 -6
- package/src/devbar.tsx +20 -3
- package/src/gate.ts +85 -0
- package/src/preview-refresh.tsx +5 -14
- package/src/preview-route.ts +39 -0
- package/src/request-url.ts +13 -0
- package/src/site-editor.tsx +30 -18
- package/src/studio-client.tsx +62 -0
- package/src/studio.tsx +144 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@backmatter/next",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.23",
|
|
4
4
|
"description": "Experimental Next.js adapter for Backmatter. APIs will break. Not ready for production.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"./cms-proxy": "./src/cms-proxy.ts",
|
|
34
34
|
"./proxy": "./src/proxy.ts",
|
|
35
35
|
"./middleware": "./src/middleware.ts",
|
|
36
|
-
"./page": "./src/page-render.ts"
|
|
36
|
+
"./page": "./src/page-render.ts",
|
|
37
|
+
"./studio": "./src/studio.tsx"
|
|
37
38
|
},
|
|
38
39
|
"files": [
|
|
39
40
|
"src",
|
|
@@ -46,9 +47,10 @@
|
|
|
46
47
|
"test": "bun test"
|
|
47
48
|
},
|
|
48
49
|
"dependencies": {
|
|
49
|
-
"@backmatter/core": "^0.0.
|
|
50
|
+
"@backmatter/core": "^0.0.20",
|
|
50
51
|
"@mdx-js/mdx": "^3.1.1",
|
|
51
|
-
"remark-gfm": "^4.0.1"
|
|
52
|
+
"remark-gfm": "^4.0.1",
|
|
53
|
+
"@backmatter/studio": "^0.0.1"
|
|
52
54
|
},
|
|
53
55
|
"peerDependencies": {
|
|
54
56
|
"next": ">=16.3.0",
|
|
@@ -56,7 +58,9 @@
|
|
|
56
58
|
"react-dom": ">=19"
|
|
57
59
|
},
|
|
58
60
|
"peerDependenciesMeta": {
|
|
59
|
-
"next": {
|
|
61
|
+
"next": {
|
|
62
|
+
"optional": true
|
|
63
|
+
}
|
|
60
64
|
},
|
|
61
65
|
"devDependencies": {
|
|
62
66
|
"@types/bun": "^1.3.14",
|
package/src/cms-proxy.ts
CHANGED
|
@@ -6,21 +6,41 @@ import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
|
|
|
6
6
|
|
|
7
7
|
const DOC = "/workspaces/[^/]+/docs/[^/]+/[^/]+/[^/]+";
|
|
8
8
|
|
|
9
|
+
const WS = "/workspaces/[^/]+";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Everything the Studio does from a customer site. Admin surfaces that change
|
|
13
|
+
* the workspace itself (members, settings, GitHub binding, tokens) are absent
|
|
14
|
+
* on purpose: they stay on the dashboard.
|
|
15
|
+
*/
|
|
9
16
|
const ALLOWED: Array<{ method: string; pattern: RegExp }> = [
|
|
10
|
-
{ method: "GET", pattern: /^\/
|
|
17
|
+
{ method: "GET", pattern: /^\/me$/ },
|
|
18
|
+
{ method: "GET", pattern: /^\/workspaces$/ },
|
|
19
|
+
{ method: "GET", pattern: new RegExp(`^${WS}$`) },
|
|
20
|
+
{ method: "GET", pattern: new RegExp(`^${WS}/replica$`) },
|
|
21
|
+
{ method: "GET", pattern: new RegExp(`^${WS}/schema$`) },
|
|
22
|
+
{ method: "GET", pattern: new RegExp(`^${WS}/docs$`) },
|
|
11
23
|
{ method: "GET", pattern: new RegExp(`^${DOC}$`) },
|
|
12
24
|
{ method: "PUT", pattern: new RegExp(`^${DOC}$`) },
|
|
13
25
|
{ method: "DELETE", pattern: new RegExp(`^${DOC}$`) },
|
|
14
26
|
{ method: "DELETE", pattern: new RegExp(`^${DOC}/tree$`) },
|
|
15
27
|
{ method: "POST", pattern: new RegExp(`^${DOC}/resolve$`) },
|
|
16
|
-
{ method: "GET", pattern:
|
|
17
|
-
{ method: "POST", pattern:
|
|
18
|
-
{ method: "POST", pattern:
|
|
28
|
+
{ method: "GET", pattern: new RegExp(`^${WS}/git$`) },
|
|
29
|
+
{ method: "POST", pattern: new RegExp(`^${WS}/git/commit$`) },
|
|
30
|
+
{ method: "POST", pattern: new RegExp(`^${WS}/git/checkout$`) },
|
|
31
|
+
{ method: "POST", pattern: new RegExp(`^${WS}/git/branches$`) },
|
|
32
|
+
{ method: "POST", pattern: new RegExp(`^${WS}/preview-ticket$`) },
|
|
19
33
|
{ method: "GET", pattern: /^\/preview\/workspaces\/[^/]+\/docs\/[^/]+\/[^/]+\/[^/]+\/stream$/ },
|
|
20
34
|
{ method: "GET", pattern: /^\/preview\/workspaces\/[^/]+\/tree\/stream$/ },
|
|
21
|
-
{ method: "GET", pattern:
|
|
22
|
-
{ method: "
|
|
23
|
-
{ method: "POST", pattern:
|
|
35
|
+
{ method: "GET", pattern: new RegExp(`^${WS}/ai/models$`) },
|
|
36
|
+
{ method: "GET", pattern: new RegExp(`^${WS}/ai/threads$`) },
|
|
37
|
+
{ method: "POST", pattern: new RegExp(`^${WS}/ai/threads$`) },
|
|
38
|
+
{ method: "GET", pattern: new RegExp(`^${WS}/ai/threads/[^/]+$`) },
|
|
39
|
+
{ method: "PATCH", pattern: new RegExp(`^${WS}/ai/threads/[^/]+$`) },
|
|
40
|
+
{ method: "DELETE", pattern: new RegExp(`^${WS}/ai/threads/[^/]+$`) },
|
|
41
|
+
{ method: "POST", pattern: new RegExp(`^${WS}/ai/threads/[^/]+/messages$`) },
|
|
42
|
+
{ method: "POST", pattern: new RegExp(`^${WS}/ai/threads/[^/]+/messages/[^/]+/changes/(keep-all|undo-all)$`) },
|
|
43
|
+
{ method: "POST", pattern: new RegExp(`^${WS}/ai/changes/[^/]+/(keep|undo)$`) },
|
|
24
44
|
{ method: "GET", pattern: /^\/preview\/session$/ },
|
|
25
45
|
{ method: "POST", pattern: /^\/preview\/session\/renew$/ },
|
|
26
46
|
];
|
|
@@ -35,6 +55,40 @@ export function workspaceIdFromProxyPath(path: string): string | null {
|
|
|
35
55
|
return match?.[1] ?? null;
|
|
36
56
|
}
|
|
37
57
|
|
|
58
|
+
const WORKSPACE_ID = /^ws_/;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The Studio addresses a workspace by slug, as the dashboard does. The proxy
|
|
62
|
+
* cannot verify a slug, so a slug segment is rewritten to the session's
|
|
63
|
+
* workspace id — the session is scoped to exactly one workspace anyway, and
|
|
64
|
+
* the API still checks the token against the workspace it resolves. A
|
|
65
|
+
* different workspace *id* is refused outright.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveProxyPath(
|
|
68
|
+
path: string,
|
|
69
|
+
sessionWorkspaceId: string,
|
|
70
|
+
): { ok: true; path: string } | { ok: false } {
|
|
71
|
+
const segment = workspaceIdFromProxyPath(path);
|
|
72
|
+
if (!segment || segment === sessionWorkspaceId) return { ok: true, path };
|
|
73
|
+
if (WORKSPACE_ID.test(segment)) return { ok: false };
|
|
74
|
+
return {
|
|
75
|
+
ok: true,
|
|
76
|
+
path: path.replace(
|
|
77
|
+
/^(\/(?:preview\/)?workspaces\/)[^/]+/,
|
|
78
|
+
(_m, prefix: string) => `${prefix}${sessionWorkspaceId}`,
|
|
79
|
+
),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Public origin of the site, behind a proxy when forwarded headers say so. */
|
|
84
|
+
export function siteOrigin(req: Request, url: URL): string {
|
|
85
|
+
const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? url.host;
|
|
86
|
+
const proto =
|
|
87
|
+
req.headers.get("x-forwarded-proto") ??
|
|
88
|
+
(host.startsWith("localhost") || host.startsWith("127.0.0.1") ? "http" : url.protocol.replace(":", ""));
|
|
89
|
+
return `${proto}://${host}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
38
92
|
function cookieValue(header: string | null, name: string): string | undefined {
|
|
39
93
|
if (!header) return undefined;
|
|
40
94
|
for (const part of header.split(";")) {
|
|
@@ -74,15 +128,15 @@ export function createCmsProxy(
|
|
|
74
128
|
const token = await readSessionToken(req);
|
|
75
129
|
const session = await readPreviewSession(secret, token);
|
|
76
130
|
if (!session) return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
77
|
-
const
|
|
78
|
-
if (
|
|
131
|
+
const resolved = resolveProxyPath(path, session.workspaceId);
|
|
132
|
+
if (!resolved.ok) {
|
|
79
133
|
return Response.json({ error: "forbidden" }, { status: 403 });
|
|
80
134
|
}
|
|
81
135
|
if (cms.config.workspaceId && session.workspaceId !== cms.config.workspaceId) {
|
|
82
136
|
return Response.json({ error: "forbidden" }, { status: 403 });
|
|
83
137
|
}
|
|
84
138
|
|
|
85
|
-
const upstream = new URL(`${apiUrl.replace(/\/$/, "")}${path}`);
|
|
139
|
+
const upstream = new URL(`${apiUrl.replace(/\/$/, "")}${resolved.path}`);
|
|
86
140
|
upstream.search = url.search;
|
|
87
141
|
const headers = new Headers();
|
|
88
142
|
headers.set("Authorization", `Bearer ${token}`);
|
|
@@ -90,6 +144,10 @@ export function createCmsProxy(
|
|
|
90
144
|
if (contentType) headers.set("Content-Type", contentType);
|
|
91
145
|
const accept = req.headers.get("accept");
|
|
92
146
|
if (accept) headers.set("Accept", accept);
|
|
147
|
+
const studio = req.headers.get("x-backmatter-studio");
|
|
148
|
+
if (studio) headers.set("X-Backmatter-Studio", studio);
|
|
149
|
+
// Which site this Studio runs on, so the dashboard can list versions per site.
|
|
150
|
+
headers.set("X-Backmatter-Site", siteOrigin(req, url));
|
|
93
151
|
|
|
94
152
|
const hasBody = req.method !== "GET" && req.method !== "HEAD";
|
|
95
153
|
const res = await forward(upstream, {
|
|
@@ -104,5 +162,5 @@ export function createCmsProxy(
|
|
|
104
162
|
return new Response(res.body, { status: res.status, headers: responseHeaders });
|
|
105
163
|
};
|
|
106
164
|
|
|
107
|
-
return { GET: handle, POST: handle, PUT: handle, DELETE: handle };
|
|
165
|
+
return { GET: handle, POST: handle, PUT: handle, DELETE: handle, PATCH: handle };
|
|
108
166
|
}
|
package/src/css.d.ts
ADDED
package/src/devbar-client.tsx
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// packages/next/src/devbar-client.tsx
|
|
2
2
|
"use client";
|
|
3
3
|
|
|
4
|
-
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
|
|
5
|
-
import { SiteChrome } from "./site-editor";
|
|
4
|
+
import { Suspense, lazy, useEffect, useState, type CSSProperties, type ReactNode } from "react";
|
|
6
5
|
import {
|
|
7
6
|
clearHostPreviewSession,
|
|
8
7
|
fetchCmsSession,
|
|
@@ -10,6 +9,18 @@ import {
|
|
|
10
9
|
resolveDevMode,
|
|
11
10
|
type CmsSessionUser,
|
|
12
11
|
} from "@backmatter/core/preview";
|
|
12
|
+
import {
|
|
13
|
+
ENTER_ATTEMPT_KEY,
|
|
14
|
+
enterPath,
|
|
15
|
+
gateOpen,
|
|
16
|
+
isGateShortcut,
|
|
17
|
+
markerCookie,
|
|
18
|
+
recentEnterAttempt,
|
|
19
|
+
stripOptInParam,
|
|
20
|
+
} from "./gate";
|
|
21
|
+
|
|
22
|
+
/** The bar and its panels load only after the gate opens. */
|
|
23
|
+
const SiteChrome = lazy(() => import("./site-editor").then((m) => ({ default: m.SiteChrome })));
|
|
13
24
|
|
|
14
25
|
export type { CmsSessionUser };
|
|
15
26
|
|
|
@@ -276,8 +287,90 @@ export function DevBarView({
|
|
|
276
287
|
);
|
|
277
288
|
}
|
|
278
289
|
|
|
279
|
-
|
|
280
|
-
|
|
290
|
+
const DEFAULT_PREVIEW_ENTRY_PATH = "/api/backmatter/preview";
|
|
291
|
+
|
|
292
|
+
type Gate = "closed" | "open";
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Opt-in check with no network: `?backmatter=1` (then stripped from the URL and
|
|
296
|
+
* remembered in the marker cookie), the marker itself, or the desktop
|
|
297
|
+
* shortcut. Public visitors stop here.
|
|
298
|
+
*/
|
|
299
|
+
function useGate(): Gate {
|
|
300
|
+
const [gate, setGate] = useState<Gate>("closed");
|
|
301
|
+
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
// Framed by an editor (the Studio's split preview, the dashboard's split
|
|
304
|
+
// view): that editor is the chrome, so the page shows no bar of its own.
|
|
305
|
+
if (window.self !== window.top) return;
|
|
306
|
+
const secure = location.protocol === "https:";
|
|
307
|
+
if (gateOpen({ search: location.search, cookie: document.cookie })) {
|
|
308
|
+
document.cookie = markerCookie(true, secure);
|
|
309
|
+
const clean = stripOptInParam(location.href);
|
|
310
|
+
if (clean) history.replaceState(history.state, "", clean);
|
|
311
|
+
setGate("open");
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const onKey = (e: KeyboardEvent) => {
|
|
315
|
+
if (!isGateShortcut(e)) return;
|
|
316
|
+
e.preventDefault();
|
|
317
|
+
document.cookie = markerCookie(true, secure);
|
|
318
|
+
setGate("open");
|
|
319
|
+
};
|
|
320
|
+
window.addEventListener("keydown", onKey);
|
|
321
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
322
|
+
}, []);
|
|
323
|
+
|
|
324
|
+
return gate;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Past the gate: ask this origin for the session. None means a top-level
|
|
329
|
+
* bounce to the dashboard, which returns here with a ticket. A return without
|
|
330
|
+
* a session (login abandoned, site not in the workspace) clears the marker
|
|
331
|
+
* instead of bouncing again.
|
|
332
|
+
*/
|
|
333
|
+
function useSiteSession(gate: Gate, previewEntryPath: string): DevModeState {
|
|
334
|
+
const [state, setState] = useState<DevModeState>({ status: "hidden" });
|
|
335
|
+
|
|
336
|
+
useEffect(() => {
|
|
337
|
+
if (gate !== "open") return;
|
|
338
|
+
let cancelled = false;
|
|
339
|
+
void loadDevModeState("").then((next) => {
|
|
340
|
+
if (cancelled) return;
|
|
341
|
+
if (next.status === "ready" && next.mode === "preview") {
|
|
342
|
+
sessionStorage.removeItem(ENTER_ATTEMPT_KEY);
|
|
343
|
+
setState(next);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (recentEnterAttempt(sessionStorage.getItem(ENTER_ATTEMPT_KEY), Date.now())) {
|
|
347
|
+
sessionStorage.removeItem(ENTER_ATTEMPT_KEY);
|
|
348
|
+
document.cookie = markerCookie(false, location.protocol === "https:");
|
|
349
|
+
setState({ status: "hidden" });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
sessionStorage.setItem(ENTER_ATTEMPT_KEY, String(Date.now()));
|
|
353
|
+
location.assign(enterPath(previewEntryPath, location.pathname + location.search + location.hash));
|
|
354
|
+
});
|
|
355
|
+
return () => {
|
|
356
|
+
cancelled = true;
|
|
357
|
+
};
|
|
358
|
+
}, [gate, previewEntryPath]);
|
|
359
|
+
|
|
360
|
+
return state;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function DevBarClient({
|
|
364
|
+
studioPath,
|
|
365
|
+
previewEntryPath = DEFAULT_PREVIEW_ENTRY_PATH,
|
|
366
|
+
}: {
|
|
367
|
+
/** Unused since the bar stopped calling the CMS origin; kept for host layouts that still pass it. */
|
|
368
|
+
apiUrl?: string;
|
|
369
|
+
studioPath?: string;
|
|
370
|
+
previewEntryPath?: string;
|
|
371
|
+
}) {
|
|
372
|
+
const gate = useGate();
|
|
373
|
+
const state = useSiteSession(gate, previewEntryPath);
|
|
281
374
|
const [exiting, setExiting] = useState(false);
|
|
282
375
|
const exit = () => {
|
|
283
376
|
if (exiting) return;
|
|
@@ -286,8 +379,19 @@ export function DevBarClient({ apiUrl }: { apiUrl: string }) {
|
|
|
286
379
|
if (!ok) setExiting(false);
|
|
287
380
|
});
|
|
288
381
|
};
|
|
289
|
-
if (state.status
|
|
290
|
-
|
|
382
|
+
if (state.status !== "ready") return null;
|
|
383
|
+
if (state.mode === "preview") {
|
|
384
|
+
return (
|
|
385
|
+
<Suspense fallback={null}>
|
|
386
|
+
<SiteChrome
|
|
387
|
+
branch={state.branch}
|
|
388
|
+
tree={state.tree}
|
|
389
|
+
exiting={exiting}
|
|
390
|
+
onExit={exit}
|
|
391
|
+
studioPath={studioPath}
|
|
392
|
+
/>
|
|
393
|
+
</Suspense>
|
|
394
|
+
);
|
|
291
395
|
}
|
|
292
396
|
return <DevBarView state={state} exiting={exiting} onExit={exit} />;
|
|
293
397
|
}
|
package/src/devbar.tsx
CHANGED
|
@@ -13,7 +13,24 @@ export {
|
|
|
13
13
|
type DevModeState,
|
|
14
14
|
} from "./devbar-client";
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Host layout: `<DevBar />`. Public visitors get a gate of a few hundred
|
|
18
|
+
* bytes that makes no request; `?backmatter=1`, the `bm_ui` marker cookie or
|
|
19
|
+
* ⌘⇧E opens it. `studioPath` is where `@backmatter/studio` is mounted on this
|
|
20
|
+
* site (default `/backmatter`); the bar's panels are frames of that route.
|
|
21
|
+
* `previewEntryPath` is the ticket route (default `/api/backmatter/preview`).
|
|
22
|
+
* `apiUrl` is accepted for older layouts and no longer used.
|
|
23
|
+
*/
|
|
24
|
+
export function DevBar({
|
|
25
|
+
apiUrl,
|
|
26
|
+
studioPath,
|
|
27
|
+
previewEntryPath,
|
|
28
|
+
}: { apiUrl?: string; studioPath?: string; previewEntryPath?: string } = {}) {
|
|
29
|
+
return (
|
|
30
|
+
<DevBarClient
|
|
31
|
+
apiUrl={resolveDevBarApiUrl(apiUrl)}
|
|
32
|
+
studioPath={studioPath}
|
|
33
|
+
previewEntryPath={previewEntryPath}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
19
36
|
}
|
package/src/gate.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// packages/next/src/gate.ts
|
|
2
|
+
// The visitor gate in front of the editing bar. Pure helpers so the decision
|
|
3
|
+
// is testable without a DOM: a page opens the bar only when the visitor opted
|
|
4
|
+
// in through `?backmatter=1`, carries the marker cookie `bm_ui=1`, or presses
|
|
5
|
+
// the desktop shortcut. Anyone else gets no fetch and no editor code.
|
|
6
|
+
|
|
7
|
+
export const MARKER_COOKIE = "bm_ui";
|
|
8
|
+
export const OPT_IN_PARAM = "backmatter";
|
|
9
|
+
/** 30 days, per origin. Not HttpOnly: the gate reads it before any request. */
|
|
10
|
+
export const MARKER_TTL_S = 30 * 24 * 60 * 60;
|
|
11
|
+
/** Written when the gate bounces to the dashboard; read on return to stop a loop. */
|
|
12
|
+
export const ENTER_ATTEMPT_KEY = "backmatter:enter";
|
|
13
|
+
export const ENTER_ATTEMPT_WINDOW_MS = 60_000;
|
|
14
|
+
|
|
15
|
+
export function hasMarker(cookieHeader: string | null | undefined): boolean {
|
|
16
|
+
if (!cookieHeader) return false;
|
|
17
|
+
for (const part of cookieHeader.split(";")) {
|
|
18
|
+
const [key, ...rest] = part.trim().split("=");
|
|
19
|
+
if (key === MARKER_COOKIE) return rest.join("=") === "1";
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function hasOptInParam(search: string): boolean {
|
|
25
|
+
return new URLSearchParams(search).get(OPT_IN_PARAM) === "1";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function gateOpen(input: { search: string; cookie: string | null | undefined }): boolean {
|
|
29
|
+
return hasOptInParam(input.search) || hasMarker(input.cookie);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `document.cookie` assignment that sets or clears the marker. */
|
|
33
|
+
export function markerCookie(on: boolean, secure = false): string {
|
|
34
|
+
const parts = [
|
|
35
|
+
`${MARKER_COOKIE}=${on ? "1" : ""}`,
|
|
36
|
+
"Path=/",
|
|
37
|
+
`Max-Age=${on ? MARKER_TTL_S : 0}`,
|
|
38
|
+
"SameSite=Lax",
|
|
39
|
+
];
|
|
40
|
+
if (secure) parts.push("Secure");
|
|
41
|
+
return parts.join("; ");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The same href without `?backmatter=1`, or null when the param was absent. */
|
|
45
|
+
export function stripOptInParam(href: string): string | null {
|
|
46
|
+
const url = new URL(href);
|
|
47
|
+
if (url.searchParams.get(OPT_IN_PARAM) !== "1") return null;
|
|
48
|
+
url.searchParams.delete(OPT_IN_PARAM);
|
|
49
|
+
return url.toString();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** ⌘⇧E on macOS, Ctrl+Shift+E elsewhere. Not bound by the major browsers. */
|
|
53
|
+
export function isGateShortcut(e: {
|
|
54
|
+
key: string;
|
|
55
|
+
metaKey: boolean;
|
|
56
|
+
ctrlKey: boolean;
|
|
57
|
+
shiftKey: boolean;
|
|
58
|
+
altKey: boolean;
|
|
59
|
+
}): boolean {
|
|
60
|
+
if (!e.shiftKey || e.altKey) return false;
|
|
61
|
+
if (!(e.metaKey || e.ctrlKey)) return false;
|
|
62
|
+
return e.key === "E" || e.key === "e";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Same-origin path only: must start with one `/`. Anything else goes home. */
|
|
66
|
+
export function safeReturnPath(raw: string | null | undefined): string {
|
|
67
|
+
const value = raw?.trim() ?? "";
|
|
68
|
+
if (!value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\")) return "/";
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The host route that bounces to the dashboard `enter` and comes back to `returnPath`. */
|
|
73
|
+
export function enterPath(previewEntryPath: string, returnPath: string): string {
|
|
74
|
+
return `${previewEntryPath}?enter=1&return=${encodeURIComponent(safeReturnPath(returnPath))}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Loop guard: true when a bounce started within the window, meaning we came
|
|
79
|
+
* back from the dashboard without a session. The caller then gives up and
|
|
80
|
+
* clears the marker instead of bouncing again.
|
|
81
|
+
*/
|
|
82
|
+
export function recentEnterAttempt(stored: string | null, now: number): boolean {
|
|
83
|
+
const at = Number(stored);
|
|
84
|
+
return Number.isFinite(at) && at > 0 && now - at < ENTER_ATTEMPT_WINDOW_MS;
|
|
85
|
+
}
|
package/src/preview-refresh.tsx
CHANGED
|
@@ -39,17 +39,6 @@ function referrerOrigin(): string {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
function dashboardOriginFromMeta(): string {
|
|
43
|
-
const marked = document
|
|
44
|
-
.querySelector('meta[name="backmatter:dashboard"]')
|
|
45
|
-
?.getAttribute("content");
|
|
46
|
-
if (marked) return marked.replace(/\/$/, "");
|
|
47
|
-
if (location.hostname === "localhost" || location.hostname === "127.0.0.1") {
|
|
48
|
-
return `${location.protocol}//${location.hostname}:5173`;
|
|
49
|
-
}
|
|
50
|
-
return "";
|
|
51
|
-
}
|
|
52
|
-
|
|
53
42
|
function subscribeDocStream(
|
|
54
43
|
ref: { locale: string; type: string; slug: string },
|
|
55
44
|
onEvent: (event: { raw?: string; body?: string; frontmatter?: Record<string, unknown> }) => void,
|
|
@@ -169,13 +158,15 @@ export function PreviewClient({
|
|
|
169
158
|
...(branch ? { branch } : {}),
|
|
170
159
|
...(tree ? { tree } : {}),
|
|
171
160
|
};
|
|
161
|
+
// Framed by the dashboard's split view: the parent is the dashboard.
|
|
162
|
+
// Otherwise the Studio mounted on this site posts from same-origin frames.
|
|
172
163
|
const parentOrigin = referrerOrigin();
|
|
173
|
-
const
|
|
174
|
-
const allowed = [...originsKey.split("\0"), parentOrigin,
|
|
164
|
+
const selfOrigin = window.location.origin;
|
|
165
|
+
const allowed = [...originsKey.split("\0"), parentOrigin, selfOrigin].filter(
|
|
175
166
|
(origin) => origin.length > 0,
|
|
176
167
|
);
|
|
177
168
|
const framed = window.parent !== window;
|
|
178
|
-
const targetOrigin = framed ? parentOrigin || allowed[0] || "" :
|
|
169
|
+
const targetOrigin = framed ? parentOrigin || allowed[0] || "" : selfOrigin;
|
|
179
170
|
const windowTransport: PreviewTransport | null = targetOrigin
|
|
180
171
|
? createWindowPreviewTransport({
|
|
181
172
|
target: framed ? window.parent : { postMessage() {} },
|
package/src/preview-route.ts
CHANGED
|
@@ -8,19 +8,37 @@ import {
|
|
|
8
8
|
previewLocationWithCheckout,
|
|
9
9
|
readPreviewCookie,
|
|
10
10
|
readPreviewTicket,
|
|
11
|
+
resolveDashboardUrl,
|
|
12
|
+
studioEnterUrl,
|
|
11
13
|
writePreviewCookie,
|
|
12
14
|
type PreviewCheckout,
|
|
13
15
|
} from "@backmatter/core";
|
|
14
16
|
import { previewStreamUrl, previewTreeStreamUrl } from "@backmatter/core/preview";
|
|
15
17
|
import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
|
|
18
|
+
import { MARKER_COOKIE, MARKER_TTL_S, safeReturnPath } from "./gate";
|
|
19
|
+
import { requestUrlFrom } from "./request-url";
|
|
16
20
|
|
|
17
21
|
export type PreviewRouteDeps = {
|
|
18
22
|
enableDraft?: () => void | Promise<void>;
|
|
19
23
|
disableDraft?: () => void | Promise<void>;
|
|
20
24
|
isDraftEnabled?: () => boolean | Promise<boolean>;
|
|
21
25
|
now?: () => number;
|
|
26
|
+
/** Dashboard that holds the login, for `?enter=1`. Default: `resolveDashboardUrl()`. */
|
|
27
|
+
dashboardUrl?: string;
|
|
22
28
|
};
|
|
23
29
|
|
|
30
|
+
/** The gate's marker: readable by the page script, so never HttpOnly. */
|
|
31
|
+
function markerCookieHeader(secure: boolean, on: boolean): string {
|
|
32
|
+
const parts = [
|
|
33
|
+
`${MARKER_COOKIE}=${on ? "1" : ""}`,
|
|
34
|
+
"Path=/",
|
|
35
|
+
`Max-Age=${on ? MARKER_TTL_S : 0}`,
|
|
36
|
+
"SameSite=Lax",
|
|
37
|
+
];
|
|
38
|
+
if (secure) parts.push("Secure");
|
|
39
|
+
return parts.join("; ");
|
|
40
|
+
}
|
|
41
|
+
|
|
24
42
|
function cookieBase(name: string, secure: boolean, maxAge: number, value: string) {
|
|
25
43
|
// HTTPS is cross-site from the CMS iframe (dash → customer origin). Lax is
|
|
26
44
|
// not sent there, so draftMode can be on while the checkout cookie is missing
|
|
@@ -95,6 +113,23 @@ export function createPreviewRoute(
|
|
|
95
113
|
return Response.json({ mode: draftOn ? "preview" : "live" });
|
|
96
114
|
}
|
|
97
115
|
|
|
116
|
+
// The bar found the marker but no session: go top-level to the dashboard,
|
|
117
|
+
// which mints a ticket after login and returns to this page.
|
|
118
|
+
if (url.searchParams.get("enter") === "1") {
|
|
119
|
+
if (!cms.config.workspaceId) {
|
|
120
|
+
return Response.json({ error: "preview_not_configured" }, { status: 503 });
|
|
121
|
+
}
|
|
122
|
+
const returnUrl = requestUrlFrom(
|
|
123
|
+
(name) => req.headers.get(name),
|
|
124
|
+
safeReturnPath(url.searchParams.get("return")),
|
|
125
|
+
);
|
|
126
|
+
const dashboardUrl = resolveDashboardUrl(deps.dashboardUrl);
|
|
127
|
+
return Response.redirect(
|
|
128
|
+
studioEnterUrl(dashboardUrl, { workspaceId: cms.config.workspaceId, returnUrl }),
|
|
129
|
+
302,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
98
133
|
if (url.searchParams.get("stream") === "1") {
|
|
99
134
|
if (!secret || !cms.config.apiUrl || !cms.config.workspaceId) {
|
|
100
135
|
return new Response("preview not configured", { status: 503 });
|
|
@@ -169,6 +204,8 @@ export function createPreviewRoute(
|
|
|
169
204
|
),
|
|
170
205
|
);
|
|
171
206
|
}
|
|
207
|
+
// Entry opts the visitor in, so the bar shows on later pages without a param.
|
|
208
|
+
headers.append("Set-Cookie", markerCookieHeader(secure, true));
|
|
172
209
|
return new Response(null, { status: 302, headers });
|
|
173
210
|
};
|
|
174
211
|
|
|
@@ -178,6 +215,8 @@ export function createPreviewRoute(
|
|
|
178
215
|
const headers = new Headers();
|
|
179
216
|
headers.append("Set-Cookie", cookieBase(PREVIEW_COOKIE, secure, 0, ""));
|
|
180
217
|
headers.append("Set-Cookie", cookieBase(PREVIEW_SESSION_COOKIE, secure, 0, ""));
|
|
218
|
+
// Exit also drops the marker; otherwise the next page would bounce straight back in.
|
|
219
|
+
headers.append("Set-Cookie", markerCookieHeader(secure, false));
|
|
181
220
|
return new Response(null, { status: 204, headers });
|
|
182
221
|
};
|
|
183
222
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// packages/next/src/request-url.ts
|
|
2
|
+
|
|
3
|
+
/** Absolute URL of the current request, from forwarded headers when behind a proxy. */
|
|
4
|
+
export function requestUrlFrom(
|
|
5
|
+
get: (name: string) => string | null,
|
|
6
|
+
pathWithSearch: string,
|
|
7
|
+
): string {
|
|
8
|
+
const host = get("x-forwarded-host") ?? get("host") ?? "localhost:3000";
|
|
9
|
+
const proto =
|
|
10
|
+
get("x-forwarded-proto") ??
|
|
11
|
+
(host.startsWith("localhost") || host.startsWith("127.0.0.1") ? "http" : "https");
|
|
12
|
+
return `${proto}://${host}${pathWithSearch}`;
|
|
13
|
+
}
|
package/src/site-editor.tsx
CHANGED
|
@@ -32,14 +32,17 @@ function useSiteDoc(): SiteDocRef | null {
|
|
|
32
32
|
return doc;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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(/\/+$/, "")}`;
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
export function SiteChrome({
|
|
@@ -47,11 +50,14 @@ export function SiteChrome({
|
|
|
47
50
|
tree,
|
|
48
51
|
exiting,
|
|
49
52
|
onExit,
|
|
53
|
+
studioPath = DEFAULT_STUDIO_PATH,
|
|
50
54
|
}: {
|
|
51
55
|
branch?: string;
|
|
52
56
|
tree?: string;
|
|
53
57
|
exiting?: boolean;
|
|
54
58
|
onExit: () => void;
|
|
59
|
+
/** Where `@backmatter/studio` is mounted on this site. */
|
|
60
|
+
studioPath?: string;
|
|
55
61
|
}) {
|
|
56
62
|
const router = useRouter();
|
|
57
63
|
const doc = useSiteDoc();
|
|
@@ -61,12 +67,14 @@ export function SiteChrome({
|
|
|
61
67
|
const [chatOpen, setChatOpen] = useState(false);
|
|
62
68
|
const [publishOpen, setPublishOpen] = useState(false);
|
|
63
69
|
const [changeCount, setChangeCount] = useState(0);
|
|
64
|
-
|
|
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;
|
|
65
73
|
const editorFrame = useRef<HTMLIFrameElement>(null);
|
|
66
74
|
|
|
67
75
|
useEffect(() => {
|
|
68
|
-
|
|
69
|
-
}, []);
|
|
76
|
+
setBase(studioBase(studioPath));
|
|
77
|
+
}, [studioPath]);
|
|
70
78
|
|
|
71
79
|
useEffect(() => {
|
|
72
80
|
function onKey(event: KeyboardEvent) {
|
|
@@ -113,8 +121,8 @@ export function SiteChrome({
|
|
|
113
121
|
|
|
114
122
|
const label = doc ? `${doc.type}/${doc.slug}` : "No document";
|
|
115
123
|
const editorSrc =
|
|
116
|
-
doc &&
|
|
117
|
-
? `${
|
|
124
|
+
doc && base
|
|
125
|
+
? `${base}/workspaces/${doc.workspaceId}/docs/${doc.locale}/${doc.type}/${doc.slug}?embed=1`
|
|
118
126
|
: "";
|
|
119
127
|
|
|
120
128
|
function onChatWrote() {
|
|
@@ -143,12 +151,13 @@ export function SiteChrome({
|
|
|
143
151
|
hidden={!chatOpen}
|
|
144
152
|
onWrote={onChatWrote}
|
|
145
153
|
onClosed={() => setChatOpen(false)}
|
|
154
|
+
base={base}
|
|
146
155
|
/>
|
|
147
156
|
) : null}
|
|
148
|
-
{doc &&
|
|
157
|
+
{doc && base ? (
|
|
149
158
|
<iframe
|
|
150
159
|
title="Publish"
|
|
151
|
-
src={`${
|
|
160
|
+
src={`${base}/embed/publish/${doc.workspaceId}`}
|
|
152
161
|
style={{
|
|
153
162
|
...publishStyle,
|
|
154
163
|
visibility: publishOpen ? "visible" : "hidden",
|
|
@@ -201,11 +210,14 @@ function CmsChat({
|
|
|
201
210
|
hidden,
|
|
202
211
|
onWrote,
|
|
203
212
|
onClosed,
|
|
213
|
+
base,
|
|
204
214
|
}: {
|
|
205
215
|
doc: SiteDocRef;
|
|
206
216
|
hidden: boolean;
|
|
207
217
|
onWrote: () => void;
|
|
208
218
|
onClosed: () => void;
|
|
219
|
+
/** Studio origin plus base path. */
|
|
220
|
+
base: string;
|
|
209
221
|
}) {
|
|
210
222
|
const frame = useRef<HTMLIFrameElement>(null);
|
|
211
223
|
const onWroteRef = useRef(onWrote);
|
|
@@ -214,7 +226,7 @@ function CmsChat({
|
|
|
214
226
|
onWroteRef.current = onWrote;
|
|
215
227
|
onClosedRef.current = onClosed;
|
|
216
228
|
hiddenRef.current = hidden;
|
|
217
|
-
const origin =
|
|
229
|
+
const origin = base ? new URL(base).origin : "";
|
|
218
230
|
|
|
219
231
|
function openChat() {
|
|
220
232
|
const frameEl = frame.current;
|
|
@@ -246,12 +258,12 @@ function CmsChat({
|
|
|
246
258
|
return () => window.removeEventListener("message", onMessage);
|
|
247
259
|
}, [doc.locale, doc.type, doc.slug, origin]);
|
|
248
260
|
|
|
249
|
-
if (!
|
|
261
|
+
if (!base) return null;
|
|
250
262
|
return (
|
|
251
263
|
<iframe
|
|
252
264
|
ref={frame}
|
|
253
265
|
title="AI chat"
|
|
254
|
-
src={`${
|
|
266
|
+
src={`${base}/embed/chat/${doc.workspaceId}`}
|
|
255
267
|
style={{
|
|
256
268
|
...chatStyle,
|
|
257
269
|
visibility: hidden ? "hidden" : "visible",
|
|
@@ -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){}})();`;
|