@backmatter/next 0.0.22 → 0.0.24
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 +159 -41
- package/src/devbar-motion.ts +58 -0
- package/src/devbar.tsx +24 -3
- package/src/gate.ts +85 -0
- package/src/page-render.ts +13 -3
- package/src/preview-refresh.tsx +5 -14
- package/src/preview-route.ts +147 -8
- package/src/request-url.ts +13 -0
- package/src/site-editor.tsx +187 -57
- package/src/site-pointer.ts +199 -0
- package/src/studio-client.tsx +80 -0
- package/src/studio.tsx +157 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@backmatter/next",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.24",
|
|
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.2"
|
|
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,20 +1,34 @@
|
|
|
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,
|
|
9
8
|
fetchHostPreviewSession,
|
|
10
9
|
resolveDevMode,
|
|
11
10
|
type CmsSessionUser,
|
|
11
|
+
type HostSessionReason,
|
|
12
12
|
} from "@backmatter/core/preview";
|
|
13
|
+
import {
|
|
14
|
+
ENTER_ATTEMPT_KEY,
|
|
15
|
+
enterPath,
|
|
16
|
+
hasMarker,
|
|
17
|
+
hasOptInParam,
|
|
18
|
+
isGateShortcut,
|
|
19
|
+
markerCookie,
|
|
20
|
+
recentEnterAttempt,
|
|
21
|
+
stripOptInParam,
|
|
22
|
+
} from "./gate";
|
|
23
|
+
import { DEVBAR_CLASS, DEVBAR_ENTER_CSS, devbarShellStyle } from "./devbar-motion";
|
|
24
|
+
|
|
25
|
+
/** The bar and its panels load only after the gate opens. */
|
|
26
|
+
const SiteChrome = lazy(() => import("./site-editor").then((m) => ({ default: m.SiteChrome })));
|
|
13
27
|
|
|
14
28
|
export type { CmsSessionUser };
|
|
15
29
|
|
|
16
30
|
export type DevModeState =
|
|
17
|
-
| { status: "hidden" }
|
|
31
|
+
| { status: "hidden"; reason?: HostSessionReason }
|
|
18
32
|
| {
|
|
19
33
|
status: "ready";
|
|
20
34
|
user?: CmsSessionUser;
|
|
@@ -23,11 +37,33 @@ export type DevModeState =
|
|
|
23
37
|
tree?: string;
|
|
24
38
|
};
|
|
25
39
|
|
|
40
|
+
export type SessionGateAction = "show" | "bounce" | "quiet" | "wait";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What the bar does after the site-session check. A verified session shows
|
|
44
|
+
* the bar. An explicit enter (shortcut or `?backmatter=1`) with no session
|
|
45
|
+
* sends the whole window to the dashboard. A leftover marker stays on the
|
|
46
|
+
* public page. An unreachable API waits and keeps the marker.
|
|
47
|
+
*/
|
|
48
|
+
export function sessionGateAction(input: {
|
|
49
|
+
explicit: boolean;
|
|
50
|
+
enabled: boolean;
|
|
51
|
+
reason: HostSessionReason;
|
|
52
|
+
recentAttempt: boolean;
|
|
53
|
+
}): SessionGateAction {
|
|
54
|
+
if (input.enabled) return "show";
|
|
55
|
+
if (input.reason === "unavailable") return "wait";
|
|
56
|
+
if (input.explicit && !input.recentAttempt) return "bounce";
|
|
57
|
+
return "quiet";
|
|
58
|
+
}
|
|
59
|
+
|
|
26
60
|
export function devModeState(
|
|
27
61
|
user: CmsSessionUser | null,
|
|
28
|
-
session: { enabled: boolean; branch?: string; tree?: string },
|
|
62
|
+
session: { enabled: boolean; branch?: string; tree?: string; reason?: HostSessionReason },
|
|
29
63
|
): DevModeState {
|
|
30
|
-
if (!user && !session.enabled)
|
|
64
|
+
if (!user && !session.enabled) {
|
|
65
|
+
return session.reason ? { status: "hidden", reason: session.reason } : { status: "hidden" };
|
|
66
|
+
}
|
|
31
67
|
const resolved = resolveDevMode(session);
|
|
32
68
|
return {
|
|
33
69
|
status: "ready",
|
|
@@ -66,40 +102,15 @@ export function useDevMode(options: { apiUrl: string }): DevModeState {
|
|
|
66
102
|
return state;
|
|
67
103
|
}
|
|
68
104
|
|
|
69
|
-
/** Above typical host sticky headers/modals; below max 32-bit int so a host can still overlay. */
|
|
70
|
-
const DEVBAR_Z_INDEX = 2147483000;
|
|
71
|
-
|
|
72
105
|
const barStyle: CSSProperties = {
|
|
73
|
-
|
|
74
|
-
bottom: 0,
|
|
75
|
-
left: "50%",
|
|
76
|
-
transform: "translateX(-50%)",
|
|
77
|
-
zIndex: DEVBAR_Z_INDEX,
|
|
78
|
-
isolation: "isolate",
|
|
106
|
+
...devbarShellStyle,
|
|
79
107
|
pointerEvents: "auto",
|
|
80
|
-
display: "flex",
|
|
81
|
-
alignItems: "center",
|
|
82
108
|
gap: 10,
|
|
83
|
-
minHeight: 32,
|
|
84
|
-
maxWidth: "min(520px, calc(100vw - 24px))",
|
|
85
109
|
padding: "0 12px",
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
color: "#71717a",
|
|
89
|
-
border: "1px solid #e4e4e7",
|
|
90
|
-
borderBottom: "none",
|
|
91
|
-
borderTopLeftRadius: 8,
|
|
92
|
-
borderTopRightRadius: 8,
|
|
93
|
-
borderBottomLeftRadius: 0,
|
|
94
|
-
borderBottomRightRadius: 0,
|
|
95
|
-
fontFamily:
|
|
96
|
-
'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
97
|
-
fontSize: 11,
|
|
110
|
+
fontFamily: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
111
|
+
fontSize: 12,
|
|
98
112
|
fontWeight: 500,
|
|
99
|
-
lineHeight: "
|
|
100
|
-
WebkitFontSmoothing: "antialiased",
|
|
101
|
-
fontVariantNumeric: "tabular-nums",
|
|
102
|
-
boxSizing: "border-box",
|
|
113
|
+
lineHeight: "44px",
|
|
103
114
|
};
|
|
104
115
|
|
|
105
116
|
const brandStyle: CSSProperties = {
|
|
@@ -140,8 +151,8 @@ const exitButtonStyle: CSSProperties = {
|
|
|
140
151
|
fontFamily: "inherit",
|
|
141
152
|
fontSize: 11,
|
|
142
153
|
fontWeight: 500,
|
|
143
|
-
lineHeight: "
|
|
144
|
-
minHeight:
|
|
154
|
+
lineHeight: "44px",
|
|
155
|
+
minHeight: 44,
|
|
145
156
|
touchAction: "manipulation",
|
|
146
157
|
WebkitFontSmoothing: "antialiased",
|
|
147
158
|
};
|
|
@@ -206,8 +217,9 @@ export function DevBarView({
|
|
|
206
217
|
const ariaLabel = parts.join(", ");
|
|
207
218
|
|
|
208
219
|
return (
|
|
209
|
-
<div role="status" aria-label={ariaLabel} style={barStyle}>
|
|
220
|
+
<div role="status" aria-label={ariaLabel} className={DEVBAR_CLASS} style={barStyle}>
|
|
210
221
|
<style>{`
|
|
222
|
+
${DEVBAR_ENTER_CSS}
|
|
211
223
|
@media (hover: hover) {
|
|
212
224
|
button.backmatter-devbar-exit:hover:not(:disabled) { color: #71717a; }
|
|
213
225
|
}
|
|
@@ -276,8 +288,102 @@ export function DevBarView({
|
|
|
276
288
|
);
|
|
277
289
|
}
|
|
278
290
|
|
|
279
|
-
|
|
280
|
-
|
|
291
|
+
const DEFAULT_PREVIEW_ENTRY_PATH = "/api/backmatter/preview";
|
|
292
|
+
|
|
293
|
+
type Gate = "closed" | "resume" | "enter";
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Opt-in check with no network. Public visitors stop here. `?backmatter=1`
|
|
297
|
+
* and the shortcut are an explicit enter. The marker only resumes a session
|
|
298
|
+
* that is still valid.
|
|
299
|
+
*/
|
|
300
|
+
function useGate(): Gate {
|
|
301
|
+
const [gate, setGate] = useState<Gate>("closed");
|
|
302
|
+
|
|
303
|
+
useEffect(() => {
|
|
304
|
+
// Framed by an editor (the Studio's split preview, the dashboard's split
|
|
305
|
+
// view): that editor is the chrome, so the page shows no bar of its own.
|
|
306
|
+
if (window.self !== window.top) return;
|
|
307
|
+
const secure = location.protocol === "https:";
|
|
308
|
+
const explicit = hasOptInParam(location.search);
|
|
309
|
+
if (explicit || hasMarker(document.cookie)) {
|
|
310
|
+
document.cookie = markerCookie(true, secure);
|
|
311
|
+
const clean = stripOptInParam(location.href);
|
|
312
|
+
if (clean) history.replaceState(history.state, "", clean);
|
|
313
|
+
setGate(explicit ? "enter" : "resume");
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const onKey = (e: KeyboardEvent) => {
|
|
317
|
+
if (!isGateShortcut(e)) return;
|
|
318
|
+
e.preventDefault();
|
|
319
|
+
document.cookie = markerCookie(true, secure);
|
|
320
|
+
setGate("enter");
|
|
321
|
+
};
|
|
322
|
+
window.addEventListener("keydown", onKey);
|
|
323
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
324
|
+
}, []);
|
|
325
|
+
|
|
326
|
+
return gate;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Past the gate: ask this origin whether the site session still verifies.
|
|
331
|
+
* Only an explicit enter with no session leaves the page, and it leaves
|
|
332
|
+
* top-level. A stale marker stays on the public page.
|
|
333
|
+
*/
|
|
334
|
+
function useSiteSession(gate: Gate, previewEntryPath: string): DevModeState {
|
|
335
|
+
const [state, setState] = useState<DevModeState>({ status: "hidden" });
|
|
336
|
+
|
|
337
|
+
useEffect(() => {
|
|
338
|
+
if (gate === "closed") return;
|
|
339
|
+
let cancelled = false;
|
|
340
|
+
void loadDevModeState("").then((next) => {
|
|
341
|
+
if (cancelled) return;
|
|
342
|
+
const enabled = next.status === "ready" && next.mode === "preview";
|
|
343
|
+
const reason = next.status === "hidden" ? (next.reason ?? "anonymous") : "anonymous";
|
|
344
|
+
const action = sessionGateAction({
|
|
345
|
+
explicit: gate === "enter",
|
|
346
|
+
enabled,
|
|
347
|
+
reason,
|
|
348
|
+
recentAttempt: recentEnterAttempt(sessionStorage.getItem(ENTER_ATTEMPT_KEY), Date.now()),
|
|
349
|
+
});
|
|
350
|
+
if (action === "show") {
|
|
351
|
+
sessionStorage.removeItem(ENTER_ATTEMPT_KEY);
|
|
352
|
+
setState(next);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (action === "wait") {
|
|
356
|
+
setState({ status: "hidden", reason: "unavailable" });
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (action === "bounce") {
|
|
360
|
+
sessionStorage.setItem(ENTER_ATTEMPT_KEY, String(Date.now()));
|
|
361
|
+
location.assign(enterPath(previewEntryPath, location.pathname + location.search + location.hash));
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
sessionStorage.removeItem(ENTER_ATTEMPT_KEY);
|
|
365
|
+
document.cookie = markerCookie(false, location.protocol === "https:");
|
|
366
|
+
setState({ status: "hidden", reason });
|
|
367
|
+
});
|
|
368
|
+
return () => {
|
|
369
|
+
cancelled = true;
|
|
370
|
+
};
|
|
371
|
+
}, [gate, previewEntryPath]);
|
|
372
|
+
|
|
373
|
+
return state;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function DevBarClient({
|
|
377
|
+
studioPath,
|
|
378
|
+
previewEntryPath = DEFAULT_PREVIEW_ENTRY_PATH,
|
|
379
|
+
}: {
|
|
380
|
+
/** Unused since the bar stopped calling the CMS origin; kept for host layouts that still pass it. */
|
|
381
|
+
apiUrl?: string;
|
|
382
|
+
studioPath?: string;
|
|
383
|
+
previewEntryPath?: string;
|
|
384
|
+
}) {
|
|
385
|
+
const gate = useGate();
|
|
386
|
+
const state = useSiteSession(gate, previewEntryPath);
|
|
281
387
|
const [exiting, setExiting] = useState(false);
|
|
282
388
|
const exit = () => {
|
|
283
389
|
if (exiting) return;
|
|
@@ -286,8 +392,20 @@ export function DevBarClient({ apiUrl }: { apiUrl: string }) {
|
|
|
286
392
|
if (!ok) setExiting(false);
|
|
287
393
|
});
|
|
288
394
|
};
|
|
289
|
-
if (state.status
|
|
290
|
-
|
|
395
|
+
if (state.status !== "ready") return null;
|
|
396
|
+
if (state.mode === "preview") {
|
|
397
|
+
return (
|
|
398
|
+
<Suspense fallback={null}>
|
|
399
|
+
<SiteChrome
|
|
400
|
+
branch={state.branch}
|
|
401
|
+
tree={state.tree}
|
|
402
|
+
exiting={exiting}
|
|
403
|
+
onExit={exit}
|
|
404
|
+
studioPath={studioPath}
|
|
405
|
+
previewEntryPath={previewEntryPath}
|
|
406
|
+
/>
|
|
407
|
+
</Suspense>
|
|
408
|
+
);
|
|
291
409
|
}
|
|
292
410
|
return <DevBarView state={state} exiting={exiting} onExit={exit} />;
|
|
293
411
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// packages/next/src/devbar-motion.ts
|
|
2
|
+
import type { CSSProperties } from "react";
|
|
3
|
+
|
|
4
|
+
/** Above typical host sticky headers; below the max 32-bit int so a host can still overlay. */
|
|
5
|
+
export const DEVBAR_Z_INDEX = 2147483000;
|
|
6
|
+
|
|
7
|
+
export const DEVBAR_CLASS = "backmatter-devbar";
|
|
8
|
+
|
|
9
|
+
/** Space panels need above the corner bar: 16px inset + 44px bar + 12px gap. */
|
|
10
|
+
export const DEVBAR_CLEARANCE =
|
|
11
|
+
"calc(max(16px, env(safe-area-inset-bottom, 0px)) + 56px)";
|
|
12
|
+
|
|
13
|
+
export const DEVBAR_RIGHT = "max(16px, env(safe-area-inset-right, 0px))";
|
|
14
|
+
|
|
15
|
+
/** Shared chrome for the corner bar. Resting position does not use transform, so the enter animation can. */
|
|
16
|
+
export const devbarShellStyle: CSSProperties = {
|
|
17
|
+
position: "fixed",
|
|
18
|
+
right: DEVBAR_RIGHT,
|
|
19
|
+
bottom: "max(16px, env(safe-area-inset-bottom, 0px))",
|
|
20
|
+
zIndex: DEVBAR_Z_INDEX,
|
|
21
|
+
isolation: "isolate",
|
|
22
|
+
display: "flex",
|
|
23
|
+
alignItems: "center",
|
|
24
|
+
width: "max-content",
|
|
25
|
+
maxWidth: "calc(100vw - 32px)",
|
|
26
|
+
minHeight: 44,
|
|
27
|
+
boxSizing: "border-box",
|
|
28
|
+
border: "1px solid #e4e4e7",
|
|
29
|
+
borderRadius: 12,
|
|
30
|
+
background: "#ffffff",
|
|
31
|
+
color: "#71717a",
|
|
32
|
+
boxShadow: "0 10px 28px rgba(24, 24, 27, 0.12), 0 1px 2px rgba(24, 24, 27, 0.06)",
|
|
33
|
+
transformOrigin: "100% 100%",
|
|
34
|
+
WebkitFontSmoothing: "antialiased",
|
|
35
|
+
fontVariantNumeric: "tabular-nums",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Plays when the bar mounts — that is when the session is ready, not on first paint. */
|
|
39
|
+
export const DEVBAR_ENTER_CSS = `
|
|
40
|
+
@keyframes backmatter-devbar-in {
|
|
41
|
+
from {
|
|
42
|
+
opacity: 0;
|
|
43
|
+
transform: translateY(10px) scale(0.98);
|
|
44
|
+
}
|
|
45
|
+
to {
|
|
46
|
+
opacity: 1;
|
|
47
|
+
transform: none;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
.${DEVBAR_CLASS} {
|
|
51
|
+
animation: backmatter-devbar-in 240ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
|
52
|
+
}
|
|
53
|
+
@media (prefers-reduced-motion: reduce) {
|
|
54
|
+
.${DEVBAR_CLASS} {
|
|
55
|
+
animation: none;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
`;
|
package/src/devbar.tsx
CHANGED
|
@@ -8,12 +8,33 @@ export {
|
|
|
8
8
|
devModeState,
|
|
9
9
|
exitHostPreview,
|
|
10
10
|
loadDevModeState,
|
|
11
|
+
sessionGateAction,
|
|
11
12
|
useDevMode,
|
|
12
13
|
type CmsSessionUser,
|
|
13
14
|
type DevModeState,
|
|
15
|
+
type SessionGateAction,
|
|
14
16
|
} from "./devbar-client";
|
|
15
17
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Host layout: `<DevBar />`. Public visitors get a gate of a few hundred
|
|
20
|
+
* bytes that makes no request. The bar appears only when the site session
|
|
21
|
+
* verifies. `?backmatter=1` or ⌘⇧E with no session sends the whole window to
|
|
22
|
+
* the dashboard; a leftover `bm_ui` marker stays on the public page.
|
|
23
|
+
* `studioPath` is where `@backmatter/studio` is mounted on this site
|
|
24
|
+
* (default `/backmatter`); the bar's panels are frames of that route.
|
|
25
|
+
* `previewEntryPath` is the ticket route (default `/api/backmatter/preview`).
|
|
26
|
+
* `apiUrl` is accepted for older layouts and no longer used.
|
|
27
|
+
*/
|
|
28
|
+
export function DevBar({
|
|
29
|
+
apiUrl,
|
|
30
|
+
studioPath,
|
|
31
|
+
previewEntryPath,
|
|
32
|
+
}: { apiUrl?: string; studioPath?: string; previewEntryPath?: string } = {}) {
|
|
33
|
+
return (
|
|
34
|
+
<DevBarClient
|
|
35
|
+
apiUrl={resolveDevBarApiUrl(apiUrl)}
|
|
36
|
+
studioPath={studioPath}
|
|
37
|
+
previewEntryPath={previewEntryPath}
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
19
40
|
}
|
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/page-render.ts
CHANGED
|
@@ -87,7 +87,7 @@ export async function renderPage(
|
|
|
87
87
|
): Promise<ReactNode> {
|
|
88
88
|
const blocks = asBlocks(doc.frontmatter.blocks);
|
|
89
89
|
const children = await Promise.all(
|
|
90
|
-
blocks.map(async (block) => {
|
|
90
|
+
blocks.map(async (block, index) => {
|
|
91
91
|
if (typeof block.type !== "string") return null;
|
|
92
92
|
const entry = components?.[block.type];
|
|
93
93
|
if (!entry) {
|
|
@@ -107,8 +107,18 @@ export async function renderPage(
|
|
|
107
107
|
console.warn(`[backmatter] skipping unknown block type "${block.type}"`);
|
|
108
108
|
return null;
|
|
109
109
|
}
|
|
110
|
-
const
|
|
111
|
-
return createElement(
|
|
110
|
+
const id = typeof block.id === "string" && block.id ? block.id : String(index);
|
|
111
|
+
return createElement(
|
|
112
|
+
"div",
|
|
113
|
+
{
|
|
114
|
+
key: id,
|
|
115
|
+
"data-backmatter-block": id,
|
|
116
|
+
"data-backmatter-type": block.type,
|
|
117
|
+
"data-backmatter-index": String(index),
|
|
118
|
+
style: { display: "contents" },
|
|
119
|
+
},
|
|
120
|
+
createElement(Cmp, { ...props, [RESERVED_BLOCK_PROP]: data }),
|
|
121
|
+
);
|
|
112
122
|
}),
|
|
113
123
|
);
|
|
114
124
|
const rendered = children.filter((child) => child != null);
|