@backmatter/next 0.0.23 → 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 +2 -2
- package/src/devbar-client.tsx +69 -55
- package/src/devbar-motion.ts +58 -0
- package/src/devbar.tsx +7 -3
- package/src/page-render.ts +13 -3
- package/src/preview-route.ts +108 -8
- package/src/site-editor.tsx +157 -39
- package/src/site-pointer.ts +199 -0
- package/src/studio-client.tsx +26 -8
- package/src/studio.tsx +29 -16
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": {
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"@backmatter/core": "^0.0.20",
|
|
51
51
|
"@mdx-js/mdx": "^3.1.1",
|
|
52
52
|
"remark-gfm": "^4.0.1",
|
|
53
|
-
"@backmatter/studio": "^0.0.
|
|
53
|
+
"@backmatter/studio": "^0.0.2"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
56
|
"next": ">=16.3.0",
|
package/src/devbar-client.tsx
CHANGED
|
@@ -8,16 +8,19 @@ import {
|
|
|
8
8
|
fetchHostPreviewSession,
|
|
9
9
|
resolveDevMode,
|
|
10
10
|
type CmsSessionUser,
|
|
11
|
+
type HostSessionReason,
|
|
11
12
|
} from "@backmatter/core/preview";
|
|
12
13
|
import {
|
|
13
14
|
ENTER_ATTEMPT_KEY,
|
|
14
15
|
enterPath,
|
|
15
|
-
|
|
16
|
+
hasMarker,
|
|
17
|
+
hasOptInParam,
|
|
16
18
|
isGateShortcut,
|
|
17
19
|
markerCookie,
|
|
18
20
|
recentEnterAttempt,
|
|
19
21
|
stripOptInParam,
|
|
20
22
|
} from "./gate";
|
|
23
|
+
import { DEVBAR_CLASS, DEVBAR_ENTER_CSS, devbarShellStyle } from "./devbar-motion";
|
|
21
24
|
|
|
22
25
|
/** The bar and its panels load only after the gate opens. */
|
|
23
26
|
const SiteChrome = lazy(() => import("./site-editor").then((m) => ({ default: m.SiteChrome })));
|
|
@@ -25,7 +28,7 @@ const SiteChrome = lazy(() => import("./site-editor").then((m) => ({ default: m.
|
|
|
25
28
|
export type { CmsSessionUser };
|
|
26
29
|
|
|
27
30
|
export type DevModeState =
|
|
28
|
-
| { status: "hidden" }
|
|
31
|
+
| { status: "hidden"; reason?: HostSessionReason }
|
|
29
32
|
| {
|
|
30
33
|
status: "ready";
|
|
31
34
|
user?: CmsSessionUser;
|
|
@@ -34,11 +37,33 @@ export type DevModeState =
|
|
|
34
37
|
tree?: string;
|
|
35
38
|
};
|
|
36
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
|
+
|
|
37
60
|
export function devModeState(
|
|
38
61
|
user: CmsSessionUser | null,
|
|
39
|
-
session: { enabled: boolean; branch?: string; tree?: string },
|
|
62
|
+
session: { enabled: boolean; branch?: string; tree?: string; reason?: HostSessionReason },
|
|
40
63
|
): DevModeState {
|
|
41
|
-
if (!user && !session.enabled)
|
|
64
|
+
if (!user && !session.enabled) {
|
|
65
|
+
return session.reason ? { status: "hidden", reason: session.reason } : { status: "hidden" };
|
|
66
|
+
}
|
|
42
67
|
const resolved = resolveDevMode(session);
|
|
43
68
|
return {
|
|
44
69
|
status: "ready",
|
|
@@ -77,40 +102,15 @@ export function useDevMode(options: { apiUrl: string }): DevModeState {
|
|
|
77
102
|
return state;
|
|
78
103
|
}
|
|
79
104
|
|
|
80
|
-
/** Above typical host sticky headers/modals; below max 32-bit int so a host can still overlay. */
|
|
81
|
-
const DEVBAR_Z_INDEX = 2147483000;
|
|
82
|
-
|
|
83
105
|
const barStyle: CSSProperties = {
|
|
84
|
-
|
|
85
|
-
bottom: 0,
|
|
86
|
-
left: "50%",
|
|
87
|
-
transform: "translateX(-50%)",
|
|
88
|
-
zIndex: DEVBAR_Z_INDEX,
|
|
89
|
-
isolation: "isolate",
|
|
106
|
+
...devbarShellStyle,
|
|
90
107
|
pointerEvents: "auto",
|
|
91
|
-
display: "flex",
|
|
92
|
-
alignItems: "center",
|
|
93
108
|
gap: 10,
|
|
94
|
-
minHeight: 32,
|
|
95
|
-
maxWidth: "min(520px, calc(100vw - 24px))",
|
|
96
109
|
padding: "0 12px",
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
color: "#71717a",
|
|
100
|
-
border: "1px solid #e4e4e7",
|
|
101
|
-
borderBottom: "none",
|
|
102
|
-
borderTopLeftRadius: 8,
|
|
103
|
-
borderTopRightRadius: 8,
|
|
104
|
-
borderBottomLeftRadius: 0,
|
|
105
|
-
borderBottomRightRadius: 0,
|
|
106
|
-
fontFamily:
|
|
107
|
-
'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
108
|
-
fontSize: 11,
|
|
110
|
+
fontFamily: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
111
|
+
fontSize: 12,
|
|
109
112
|
fontWeight: 500,
|
|
110
|
-
lineHeight: "
|
|
111
|
-
WebkitFontSmoothing: "antialiased",
|
|
112
|
-
fontVariantNumeric: "tabular-nums",
|
|
113
|
-
boxSizing: "border-box",
|
|
113
|
+
lineHeight: "44px",
|
|
114
114
|
};
|
|
115
115
|
|
|
116
116
|
const brandStyle: CSSProperties = {
|
|
@@ -151,8 +151,8 @@ const exitButtonStyle: CSSProperties = {
|
|
|
151
151
|
fontFamily: "inherit",
|
|
152
152
|
fontSize: 11,
|
|
153
153
|
fontWeight: 500,
|
|
154
|
-
lineHeight: "
|
|
155
|
-
minHeight:
|
|
154
|
+
lineHeight: "44px",
|
|
155
|
+
minHeight: 44,
|
|
156
156
|
touchAction: "manipulation",
|
|
157
157
|
WebkitFontSmoothing: "antialiased",
|
|
158
158
|
};
|
|
@@ -217,8 +217,9 @@ export function DevBarView({
|
|
|
217
217
|
const ariaLabel = parts.join(", ");
|
|
218
218
|
|
|
219
219
|
return (
|
|
220
|
-
<div role="status" aria-label={ariaLabel} style={barStyle}>
|
|
220
|
+
<div role="status" aria-label={ariaLabel} className={DEVBAR_CLASS} style={barStyle}>
|
|
221
221
|
<style>{`
|
|
222
|
+
${DEVBAR_ENTER_CSS}
|
|
222
223
|
@media (hover: hover) {
|
|
223
224
|
button.backmatter-devbar-exit:hover:not(:disabled) { color: #71717a; }
|
|
224
225
|
}
|
|
@@ -289,12 +290,12 @@ export function DevBarView({
|
|
|
289
290
|
|
|
290
291
|
const DEFAULT_PREVIEW_ENTRY_PATH = "/api/backmatter/preview";
|
|
291
292
|
|
|
292
|
-
type Gate = "closed" | "
|
|
293
|
+
type Gate = "closed" | "resume" | "enter";
|
|
293
294
|
|
|
294
295
|
/**
|
|
295
|
-
* Opt-in check with no network
|
|
296
|
-
*
|
|
297
|
-
*
|
|
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.
|
|
298
299
|
*/
|
|
299
300
|
function useGate(): Gate {
|
|
300
301
|
const [gate, setGate] = useState<Gate>("closed");
|
|
@@ -304,18 +305,19 @@ function useGate(): Gate {
|
|
|
304
305
|
// view): that editor is the chrome, so the page shows no bar of its own.
|
|
305
306
|
if (window.self !== window.top) return;
|
|
306
307
|
const secure = location.protocol === "https:";
|
|
307
|
-
|
|
308
|
+
const explicit = hasOptInParam(location.search);
|
|
309
|
+
if (explicit || hasMarker(document.cookie)) {
|
|
308
310
|
document.cookie = markerCookie(true, secure);
|
|
309
311
|
const clean = stripOptInParam(location.href);
|
|
310
312
|
if (clean) history.replaceState(history.state, "", clean);
|
|
311
|
-
setGate("
|
|
313
|
+
setGate(explicit ? "enter" : "resume");
|
|
312
314
|
return;
|
|
313
315
|
}
|
|
314
316
|
const onKey = (e: KeyboardEvent) => {
|
|
315
317
|
if (!isGateShortcut(e)) return;
|
|
316
318
|
e.preventDefault();
|
|
317
319
|
document.cookie = markerCookie(true, secure);
|
|
318
|
-
setGate("
|
|
320
|
+
setGate("enter");
|
|
319
321
|
};
|
|
320
322
|
window.addEventListener("keydown", onKey);
|
|
321
323
|
return () => window.removeEventListener("keydown", onKey);
|
|
@@ -325,32 +327,43 @@ function useGate(): Gate {
|
|
|
325
327
|
}
|
|
326
328
|
|
|
327
329
|
/**
|
|
328
|
-
* Past the gate: ask this origin
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
* instead of bouncing again.
|
|
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.
|
|
332
333
|
*/
|
|
333
334
|
function useSiteSession(gate: Gate, previewEntryPath: string): DevModeState {
|
|
334
335
|
const [state, setState] = useState<DevModeState>({ status: "hidden" });
|
|
335
336
|
|
|
336
337
|
useEffect(() => {
|
|
337
|
-
if (gate
|
|
338
|
+
if (gate === "closed") return;
|
|
338
339
|
let cancelled = false;
|
|
339
340
|
void loadDevModeState("").then((next) => {
|
|
340
341
|
if (cancelled) return;
|
|
341
|
-
|
|
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") {
|
|
342
351
|
sessionStorage.removeItem(ENTER_ATTEMPT_KEY);
|
|
343
352
|
setState(next);
|
|
344
353
|
return;
|
|
345
354
|
}
|
|
346
|
-
if (
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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));
|
|
350
362
|
return;
|
|
351
363
|
}
|
|
352
|
-
sessionStorage.
|
|
353
|
-
|
|
364
|
+
sessionStorage.removeItem(ENTER_ATTEMPT_KEY);
|
|
365
|
+
document.cookie = markerCookie(false, location.protocol === "https:");
|
|
366
|
+
setState({ status: "hidden", reason });
|
|
354
367
|
});
|
|
355
368
|
return () => {
|
|
356
369
|
cancelled = true;
|
|
@@ -389,6 +402,7 @@ export function DevBarClient({
|
|
|
389
402
|
exiting={exiting}
|
|
390
403
|
onExit={exit}
|
|
391
404
|
studioPath={studioPath}
|
|
405
|
+
previewEntryPath={previewEntryPath}
|
|
392
406
|
/>
|
|
393
407
|
</Suspense>
|
|
394
408
|
);
|
|
@@ -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,16 +8,20 @@ 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
18
|
/**
|
|
17
19
|
* Host layout: `<DevBar />`. Public visitors get a gate of a few hundred
|
|
18
|
-
* bytes that makes no request
|
|
19
|
-
* ⌘⇧E
|
|
20
|
-
*
|
|
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.
|
|
21
25
|
* `previewEntryPath` is the ticket route (default `/api/backmatter/preview`).
|
|
22
26
|
* `apiUrl` is accepted for older layouts and no longer used.
|
|
23
27
|
*/
|
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);
|
package/src/preview-route.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
PREVIEW_SESSION_TTL_MS,
|
|
8
8
|
previewLocationWithCheckout,
|
|
9
9
|
readPreviewCookie,
|
|
10
|
+
readPreviewSession,
|
|
10
11
|
readPreviewTicket,
|
|
11
12
|
resolveDashboardUrl,
|
|
12
13
|
studioEnterUrl,
|
|
@@ -25,6 +26,17 @@ export type PreviewRouteDeps = {
|
|
|
25
26
|
now?: () => number;
|
|
26
27
|
/** Dashboard that holds the login, for `?enter=1`. Default: `resolveDashboardUrl()`. */
|
|
27
28
|
dashboardUrl?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Exchange the current site session for a fresh token. Default calls
|
|
31
|
+
* `POST /preview/session/renew` on the API. Null means the session is dead.
|
|
32
|
+
* Throw when the API cannot be reached; that becomes a 503 and keeps the cookies.
|
|
33
|
+
*/
|
|
34
|
+
renewSession?: (token: string) => Promise<string | null>;
|
|
35
|
+
/**
|
|
36
|
+
* Ask the API whether this site-session token is still logged in, epoch
|
|
37
|
+
* included. Default calls `GET /preview/session`.
|
|
38
|
+
*/
|
|
39
|
+
confirmSession?: (token: string) => Promise<"ok" | "invalid" | "unavailable">;
|
|
28
40
|
};
|
|
29
41
|
|
|
30
42
|
/** The gate's marker: readable by the page script, so never HttpOnly. */
|
|
@@ -73,6 +85,42 @@ async function nextDraftEnabled(): Promise<boolean> {
|
|
|
73
85
|
return (await mod.draftMode()).isEnabled;
|
|
74
86
|
}
|
|
75
87
|
|
|
88
|
+
async function renewSessionOnApi(apiUrl: string, token: string): Promise<string | null> {
|
|
89
|
+
let res: Response;
|
|
90
|
+
try {
|
|
91
|
+
res = await fetch(`${apiUrl}/preview/session/renew`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { authorization: `Bearer ${token}` },
|
|
94
|
+
cache: "no-store",
|
|
95
|
+
});
|
|
96
|
+
} catch {
|
|
97
|
+
throw new Error("renew unavailable");
|
|
98
|
+
}
|
|
99
|
+
if (res.status === 401 || res.status === 403) return null;
|
|
100
|
+
if (!res.ok) throw new Error(`renew unavailable: ${res.status}`);
|
|
101
|
+
const body = (await res.json()) as { token?: unknown };
|
|
102
|
+
return typeof body.token === "string" && body.token ? body.token : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function confirmSessionOnApi(
|
|
106
|
+
apiUrl: string | undefined,
|
|
107
|
+
token: string,
|
|
108
|
+
): Promise<"ok" | "invalid" | "unavailable"> {
|
|
109
|
+
const base = apiUrl?.replace(/\/$/, "");
|
|
110
|
+
if (!base) return "unavailable";
|
|
111
|
+
try {
|
|
112
|
+
const res = await fetch(`${base}/preview/session`, {
|
|
113
|
+
headers: { authorization: `Bearer ${token}` },
|
|
114
|
+
cache: "no-store",
|
|
115
|
+
});
|
|
116
|
+
if (res.status === 401 || res.status === 403) return "invalid";
|
|
117
|
+
if (!res.ok) return "unavailable";
|
|
118
|
+
return "ok";
|
|
119
|
+
} catch {
|
|
120
|
+
return "unavailable";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
76
124
|
function cookieFrom(req: Request, name: string): string | undefined {
|
|
77
125
|
const header = req.headers.get("cookie");
|
|
78
126
|
if (!header) return undefined;
|
|
@@ -102,15 +150,39 @@ export function createPreviewRoute(
|
|
|
102
150
|
const secure = url.protocol === "https:";
|
|
103
151
|
|
|
104
152
|
if (url.searchParams.get("session") === "1") {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
153
|
+
// The bar's only login signal. The checkout cookie and draft mode still
|
|
154
|
+
// render drafts; they do not mean the visitor is signed in.
|
|
155
|
+
const token = cookieFrom(req, PREVIEW_SESSION_COOKIE);
|
|
156
|
+
const claims = secret && token ? await readPreviewSession(secret, token) : null;
|
|
157
|
+
const workspaceOk = !cms.config.workspaceId || claims?.workspaceId === cms.config.workspaceId;
|
|
158
|
+
const clearLogin = async () => {
|
|
159
|
+
await (deps.disableDraft ?? (() => nextDraft(false)))();
|
|
160
|
+
const headers = new Headers({ "Cache-Control": "no-store" });
|
|
161
|
+
headers.append("Set-Cookie", cookieBase(PREVIEW_COOKIE, secure, 0, ""));
|
|
162
|
+
headers.append("Set-Cookie", cookieBase(PREVIEW_SESSION_COOKIE, secure, 0, ""));
|
|
163
|
+
headers.append("Set-Cookie", markerCookieHeader(secure, false));
|
|
164
|
+
return Response.json({ mode: "live", reason: "invalid" }, { headers });
|
|
165
|
+
};
|
|
166
|
+
if (!claims || !token || !workspaceOk) {
|
|
167
|
+
if (token) return clearLogin();
|
|
168
|
+
return Response.json(
|
|
169
|
+
{ mode: "live", reason: "anonymous" },
|
|
170
|
+
{ headers: { "Cache-Control": "no-store" } },
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
const confirm = deps.confirmSession ?? ((current: string) => confirmSessionOnApi(cms.config.apiUrl, current));
|
|
174
|
+
const verdict = await confirm(token);
|
|
175
|
+
if (verdict === "unavailable") {
|
|
176
|
+
return Response.json(
|
|
177
|
+
{ mode: "live", reason: "unavailable" },
|
|
178
|
+
{ headers: { "Cache-Control": "no-store" } },
|
|
179
|
+
);
|
|
111
180
|
}
|
|
112
|
-
|
|
113
|
-
return Response.json(
|
|
181
|
+
if (verdict !== "ok") return clearLogin();
|
|
182
|
+
return Response.json(
|
|
183
|
+
{ mode: "preview", branch: claims.branch, tree: claims.tree },
|
|
184
|
+
{ headers: { "Cache-Control": "no-store" } },
|
|
185
|
+
);
|
|
114
186
|
}
|
|
115
187
|
|
|
116
188
|
// The bar found the marker but no session: go top-level to the dashboard,
|
|
@@ -210,6 +282,34 @@ export function createPreviewRoute(
|
|
|
210
282
|
};
|
|
211
283
|
|
|
212
284
|
const POST = async (req: Request): Promise<Response> => {
|
|
285
|
+
const url = new URL(req.url);
|
|
286
|
+
// Slide the site session while the bar is open. The cookie is HttpOnly, so
|
|
287
|
+
// the browser cannot renew it itself; a dead session is a 401 and the bar
|
|
288
|
+
// bounces to the dashboard instead of failing the next chat send.
|
|
289
|
+
if (url.searchParams.get("renew") === "1") {
|
|
290
|
+
const token = cookieFrom(req, PREVIEW_SESSION_COOKIE);
|
|
291
|
+
const apiUrl = cms.config.apiUrl?.replace(/\/$/, "");
|
|
292
|
+
if (!token || !apiUrl) return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
293
|
+
let renewed: string | null;
|
|
294
|
+
try {
|
|
295
|
+
renewed = await (deps.renewSession ?? ((current) => renewSessionOnApi(apiUrl, current)))(token);
|
|
296
|
+
} catch {
|
|
297
|
+
return Response.json({ error: "unavailable" }, { status: 503 });
|
|
298
|
+
}
|
|
299
|
+
if (!renewed) return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
300
|
+
const headers = new Headers();
|
|
301
|
+
headers.append(
|
|
302
|
+
"Set-Cookie",
|
|
303
|
+
cookieBase(
|
|
304
|
+
PREVIEW_SESSION_COOKIE,
|
|
305
|
+
url.protocol === "https:",
|
|
306
|
+
Math.floor(PREVIEW_SESSION_TTL_MS / 1000),
|
|
307
|
+
renewed,
|
|
308
|
+
),
|
|
309
|
+
);
|
|
310
|
+
return new Response(null, { status: 204, headers });
|
|
311
|
+
}
|
|
312
|
+
|
|
213
313
|
await (deps.disableDraft ?? (() => nextDraft(false)))();
|
|
214
314
|
const secure = new URL(req.url).protocol === "https:";
|
|
215
315
|
const headers = new Headers();
|
package/src/site-editor.tsx
CHANGED
|
@@ -3,8 +3,21 @@
|
|
|
3
3
|
|
|
4
4
|
import { usePathname, useRouter } from "next/navigation";
|
|
5
5
|
import { startTransition, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
6
|
+
import {
|
|
7
|
+
DEVBAR_CLASS,
|
|
8
|
+
DEVBAR_CLEARANCE,
|
|
9
|
+
DEVBAR_ENTER_CSS,
|
|
10
|
+
DEVBAR_RIGHT,
|
|
11
|
+
devbarShellStyle,
|
|
12
|
+
} from "./devbar-motion";
|
|
13
|
+
import { markerCookie } from "./gate";
|
|
6
14
|
import { subscribeSiteDoc } from "./site-doc-bridge";
|
|
7
15
|
import { readSiteDoc, sameSiteDoc, type SiteDocRef } from "./site-doc";
|
|
16
|
+
import { installSitePointer } from "./site-pointer";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_PREVIEW_ENTRY_PATH = "/api/backmatter/preview";
|
|
19
|
+
/** Slide the 12h site session while the bar is on screen. */
|
|
20
|
+
const SESSION_RENEW_MS = 30 * 60 * 1000;
|
|
8
21
|
|
|
9
22
|
function useSiteDoc(): SiteDocRef | null {
|
|
10
23
|
const pathname = usePathname();
|
|
@@ -51,6 +64,7 @@ export function SiteChrome({
|
|
|
51
64
|
exiting,
|
|
52
65
|
onExit,
|
|
53
66
|
studioPath = DEFAULT_STUDIO_PATH,
|
|
67
|
+
previewEntryPath = DEFAULT_PREVIEW_ENTRY_PATH,
|
|
54
68
|
}: {
|
|
55
69
|
branch?: string;
|
|
56
70
|
tree?: string;
|
|
@@ -58,12 +72,13 @@ export function SiteChrome({
|
|
|
58
72
|
onExit: () => void;
|
|
59
73
|
/** Where `@backmatter/studio` is mounted on this site. */
|
|
60
74
|
studioPath?: string;
|
|
75
|
+
/** Ticket route. `POST ?renew=1` slides the site session. A 401 drops the bar. */
|
|
76
|
+
previewEntryPath?: string;
|
|
61
77
|
}) {
|
|
62
78
|
const router = useRouter();
|
|
63
79
|
const doc = useSiteDoc();
|
|
64
80
|
const [editorMounted, setEditorMounted] = useState(false);
|
|
65
81
|
const [editorOpen, setEditorOpen] = useState(false);
|
|
66
|
-
const [chatMounted, setChatMounted] = useState(false);
|
|
67
82
|
const [chatOpen, setChatOpen] = useState(false);
|
|
68
83
|
const [publishOpen, setPublishOpen] = useState(false);
|
|
69
84
|
const [changeCount, setChangeCount] = useState(0);
|
|
@@ -80,7 +95,6 @@ export function SiteChrome({
|
|
|
80
95
|
function onKey(event: KeyboardEvent) {
|
|
81
96
|
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "j") return;
|
|
82
97
|
event.preventDefault();
|
|
83
|
-
setChatMounted(true);
|
|
84
98
|
setChatOpen((open) => !open);
|
|
85
99
|
}
|
|
86
100
|
document.addEventListener("keydown", onKey, true);
|
|
@@ -105,7 +119,6 @@ export function SiteChrome({
|
|
|
105
119
|
data.key?.toLowerCase() === "j" &&
|
|
106
120
|
(data.metaKey || data.ctrlKey)
|
|
107
121
|
) {
|
|
108
|
-
setChatMounted(true);
|
|
109
122
|
setChatOpen((open) => !open);
|
|
110
123
|
}
|
|
111
124
|
if (data?.type === "backmatter:published") {
|
|
@@ -119,6 +132,48 @@ export function SiteChrome({
|
|
|
119
132
|
return () => window.removeEventListener("message", onMessage);
|
|
120
133
|
}, [origin, router]);
|
|
121
134
|
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
let stopped = false;
|
|
137
|
+
let left = false;
|
|
138
|
+
function leaveEditor() {
|
|
139
|
+
if (left || stopped) return;
|
|
140
|
+
left = true;
|
|
141
|
+
void (async () => {
|
|
142
|
+
try {
|
|
143
|
+
await fetch(previewEntryPath, { method: "POST", credentials: "same-origin", cache: "no-store" });
|
|
144
|
+
} catch {
|
|
145
|
+
// The cookies expire on their own. Still drop the bar.
|
|
146
|
+
}
|
|
147
|
+
document.cookie = markerCookie(false, location.protocol === "https:");
|
|
148
|
+
location.reload();
|
|
149
|
+
})();
|
|
150
|
+
}
|
|
151
|
+
async function renew() {
|
|
152
|
+
try {
|
|
153
|
+
const res = await fetch(`${previewEntryPath}?renew=1`, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
credentials: "same-origin",
|
|
156
|
+
cache: "no-store",
|
|
157
|
+
});
|
|
158
|
+
if (res.status === 401) leaveEditor();
|
|
159
|
+
} catch {
|
|
160
|
+
// Offline. The next send, or the next interval, tries again.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
void renew();
|
|
164
|
+
const timer = setInterval(() => void renew(), SESSION_RENEW_MS);
|
|
165
|
+
function onExpired(event: MessageEvent) {
|
|
166
|
+
if (event.origin !== origin) return;
|
|
167
|
+
if (event.data?.type === "backmatter:session-expired") leaveEditor();
|
|
168
|
+
}
|
|
169
|
+
window.addEventListener("message", onExpired);
|
|
170
|
+
return () => {
|
|
171
|
+
stopped = true;
|
|
172
|
+
clearInterval(timer);
|
|
173
|
+
window.removeEventListener("message", onExpired);
|
|
174
|
+
};
|
|
175
|
+
}, [origin, previewEntryPath]);
|
|
176
|
+
|
|
122
177
|
const label = doc ? `${doc.type}/${doc.slug}` : "No document";
|
|
123
178
|
const editorSrc =
|
|
124
179
|
doc && base
|
|
@@ -132,7 +187,33 @@ export function SiteChrome({
|
|
|
132
187
|
|
|
133
188
|
return (
|
|
134
189
|
<>
|
|
135
|
-
<style>{
|
|
190
|
+
<style>{`
|
|
191
|
+
${DEVBAR_ENTER_CSS}
|
|
192
|
+
@media (hover: hover) {
|
|
193
|
+
.${DEVBAR_CLASS} button:hover:not(:disabled) {
|
|
194
|
+
background: #f4f4f5;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
.${DEVBAR_CLASS} button:focus-visible {
|
|
198
|
+
outline: 2px solid #18181b;
|
|
199
|
+
outline-offset: 2px;
|
|
200
|
+
}
|
|
201
|
+
.${DEVBAR_CLASS} button:disabled {
|
|
202
|
+
cursor: default;
|
|
203
|
+
opacity: 0.5;
|
|
204
|
+
}
|
|
205
|
+
html.backmatter-pointing,
|
|
206
|
+
html.backmatter-pointing * {
|
|
207
|
+
cursor: crosshair !important;
|
|
208
|
+
}
|
|
209
|
+
html.backmatter-pointing .${DEVBAR_CLASS},
|
|
210
|
+
html.backmatter-pointing .${DEVBAR_CLASS} * {
|
|
211
|
+
cursor: pointer !important;
|
|
212
|
+
}
|
|
213
|
+
html.backmatter-pointing .${DEVBAR_CLASS} button:disabled {
|
|
214
|
+
cursor: default !important;
|
|
215
|
+
}
|
|
216
|
+
`}</style>
|
|
136
217
|
{editorMounted && doc && editorSrc ? (
|
|
137
218
|
<iframe
|
|
138
219
|
ref={editorFrame}
|
|
@@ -145,7 +226,7 @@ export function SiteChrome({
|
|
|
145
226
|
}}
|
|
146
227
|
/>
|
|
147
228
|
) : null}
|
|
148
|
-
{
|
|
229
|
+
{doc && base ? (
|
|
149
230
|
<CmsChat
|
|
150
231
|
doc={doc}
|
|
151
232
|
hidden={!chatOpen}
|
|
@@ -165,7 +246,7 @@ export function SiteChrome({
|
|
|
165
246
|
}}
|
|
166
247
|
/>
|
|
167
248
|
) : null}
|
|
168
|
-
<footer aria-label="Backmatter" style={barStyle}>
|
|
249
|
+
<footer aria-label="Backmatter" className={DEVBAR_CLASS} style={barStyle}>
|
|
169
250
|
<button
|
|
170
251
|
type="button"
|
|
171
252
|
aria-pressed={editorOpen}
|
|
@@ -181,17 +262,13 @@ export function SiteChrome({
|
|
|
181
262
|
<BarText>Preview</BarText>
|
|
182
263
|
{branch ? <BarText>{branch}</BarText> : null}
|
|
183
264
|
{tree ? <BarText>draft</BarText> : null}
|
|
184
|
-
<span style={{ flex: 1 }} />
|
|
185
265
|
<button type="button" aria-pressed={publishOpen} onClick={() => setPublishOpen((open) => !open)} style={barButton}>
|
|
186
266
|
Publish{changeCount ? ` ${changeCount}` : ""}
|
|
187
267
|
</button>
|
|
188
268
|
<button
|
|
189
269
|
type="button"
|
|
190
270
|
aria-pressed={chatOpen}
|
|
191
|
-
onClick={() =>
|
|
192
|
-
setChatMounted(true);
|
|
193
|
-
setChatOpen((open) => !open);
|
|
194
|
-
}}
|
|
271
|
+
onClick={() => setChatOpen((open) => !open)}
|
|
195
272
|
title="Toggle AI chat (⌘J)"
|
|
196
273
|
style={barButton}
|
|
197
274
|
>
|
|
@@ -239,6 +316,9 @@ function CmsChat({
|
|
|
239
316
|
if (!hidden) openChat();
|
|
240
317
|
}, [hidden, origin]);
|
|
241
318
|
|
|
319
|
+
const docRef = useRef(doc);
|
|
320
|
+
docRef.current = doc;
|
|
321
|
+
|
|
242
322
|
useEffect(() => {
|
|
243
323
|
const focus = { locale: doc.locale, type: doc.type, slug: doc.slug };
|
|
244
324
|
function postFocus() {
|
|
@@ -258,6 +338,41 @@ function CmsChat({
|
|
|
258
338
|
return () => window.removeEventListener("message", onMessage);
|
|
259
339
|
}, [doc.locale, doc.type, doc.slug, origin]);
|
|
260
340
|
|
|
341
|
+
useEffect(() => {
|
|
342
|
+
if (!origin) return;
|
|
343
|
+
const pointer = installSitePointer(document, {
|
|
344
|
+
bar: () => document.querySelector(`.${DEVBAR_CLASS}`),
|
|
345
|
+
ignore: (target) => {
|
|
346
|
+
if (!(target instanceof Element)) return true;
|
|
347
|
+
const frameEl = frame.current;
|
|
348
|
+
if (frameEl && (target === frameEl || frameEl.contains(target))) return true;
|
|
349
|
+
return target.closest("iframe") !== null;
|
|
350
|
+
},
|
|
351
|
+
onGrab: (grab) => {
|
|
352
|
+
frame.current?.contentWindow?.postMessage(
|
|
353
|
+
{ type: "backmatter:pointer-grab", grab, doc: docRef.current },
|
|
354
|
+
origin,
|
|
355
|
+
);
|
|
356
|
+
},
|
|
357
|
+
onEscape: () => {
|
|
358
|
+
frame.current?.contentWindow?.postMessage({ type: "backmatter:pointer", on: false }, origin);
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
function onMessage(event: MessageEvent) {
|
|
362
|
+
if (event.origin !== origin || event.source !== frame.current?.contentWindow) return;
|
|
363
|
+
const data = event.data as { type?: string; on?: boolean };
|
|
364
|
+
if (data?.type !== "backmatter:pointer" || typeof data.on !== "boolean") return;
|
|
365
|
+
if (data.on && !hiddenRef.current) pointer.arm();
|
|
366
|
+
else pointer.disarm();
|
|
367
|
+
}
|
|
368
|
+
window.addEventListener("message", onMessage);
|
|
369
|
+
return () => {
|
|
370
|
+
window.removeEventListener("message", onMessage);
|
|
371
|
+
pointer.dispose();
|
|
372
|
+
frame.current?.contentWindow?.postMessage({ type: "backmatter:pointer", on: false }, origin);
|
|
373
|
+
};
|
|
374
|
+
}, [origin, hidden]);
|
|
375
|
+
|
|
261
376
|
if (!base) return null;
|
|
262
377
|
return (
|
|
263
378
|
<iframe
|
|
@@ -278,48 +393,51 @@ function BarText({ children }: { children: ReactNode }) {
|
|
|
278
393
|
}
|
|
279
394
|
|
|
280
395
|
const barStyle: CSSProperties = {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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",
|
|
396
|
+
...devbarShellStyle,
|
|
397
|
+
gap: 2,
|
|
398
|
+
padding: "0 6px 0 10px",
|
|
399
|
+
font: '12px/1 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
297
400
|
};
|
|
298
401
|
|
|
299
402
|
const barButton: CSSProperties = {
|
|
300
403
|
border: 0,
|
|
404
|
+
borderRadius: 8,
|
|
301
405
|
background: "transparent",
|
|
302
406
|
color: "#18181b",
|
|
303
407
|
font: "inherit",
|
|
304
408
|
cursor: "pointer",
|
|
305
|
-
padding: "
|
|
409
|
+
padding: "0 10px",
|
|
410
|
+
minHeight: 44,
|
|
306
411
|
};
|
|
307
412
|
|
|
308
413
|
const docButton: CSSProperties = {
|
|
309
414
|
...barButton,
|
|
310
415
|
fontWeight: 600,
|
|
416
|
+
maxWidth: 200,
|
|
417
|
+
overflow: "hidden",
|
|
418
|
+
textOverflow: "ellipsis",
|
|
419
|
+
whiteSpace: "nowrap",
|
|
311
420
|
};
|
|
312
421
|
|
|
313
|
-
const mutedStyle: CSSProperties = {
|
|
422
|
+
const mutedStyle: CSSProperties = {
|
|
423
|
+
color: "#71717a",
|
|
424
|
+
fontSize: 12,
|
|
425
|
+
margin: 0,
|
|
426
|
+
padding: "0 6px",
|
|
427
|
+
maxWidth: 140,
|
|
428
|
+
overflow: "hidden",
|
|
429
|
+
textOverflow: "ellipsis",
|
|
430
|
+
whiteSpace: "nowrap",
|
|
431
|
+
};
|
|
314
432
|
|
|
315
433
|
const editorStyle: CSSProperties = {
|
|
316
434
|
position: "fixed",
|
|
317
435
|
left: 0,
|
|
318
436
|
right: 0,
|
|
319
|
-
bottom:
|
|
437
|
+
bottom: DEVBAR_CLEARANCE,
|
|
320
438
|
zIndex: 2147482999,
|
|
321
439
|
width: "100%",
|
|
322
|
-
height: "min(78vh, calc(100vh -
|
|
440
|
+
height: "min(78vh, calc(100vh - 104px))",
|
|
323
441
|
border: 0,
|
|
324
442
|
borderTop: "1px solid #e4e4e7",
|
|
325
443
|
background: "#ffffff",
|
|
@@ -328,11 +446,11 @@ const editorStyle: CSSProperties = {
|
|
|
328
446
|
|
|
329
447
|
const chatStyle: CSSProperties = {
|
|
330
448
|
position: "fixed",
|
|
331
|
-
right:
|
|
332
|
-
bottom:
|
|
449
|
+
right: DEVBAR_RIGHT,
|
|
450
|
+
bottom: DEVBAR_CLEARANCE,
|
|
333
451
|
zIndex: 2147483001,
|
|
334
|
-
width: "min(440px, calc(100vw -
|
|
335
|
-
height: "min(640px, calc(100vh -
|
|
452
|
+
width: "min(440px, calc(100vw - 32px))",
|
|
453
|
+
height: "min(640px, calc(100vh - 148px))",
|
|
336
454
|
border: 0,
|
|
337
455
|
borderRadius: 14,
|
|
338
456
|
background: "#f4f4f5",
|
|
@@ -341,11 +459,11 @@ const chatStyle: CSSProperties = {
|
|
|
341
459
|
|
|
342
460
|
const publishStyle: CSSProperties = {
|
|
343
461
|
position: "fixed",
|
|
344
|
-
right:
|
|
345
|
-
bottom:
|
|
462
|
+
right: DEVBAR_RIGHT,
|
|
463
|
+
bottom: DEVBAR_CLEARANCE,
|
|
346
464
|
zIndex: 2147483001,
|
|
347
|
-
width: "min(420px, calc(100vw -
|
|
348
|
-
height: "min(520px, calc(100vh -
|
|
465
|
+
width: "min(420px, calc(100vw - 32px))",
|
|
466
|
+
height: "min(520px, calc(100vh - 148px))",
|
|
349
467
|
border: "1px solid #e4e4e7",
|
|
350
468
|
borderRadius: 8,
|
|
351
469
|
background: "#ffffff",
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// packages/next/src/site-pointer.ts
|
|
2
|
+
// Hit-test a click on the host page and draw the armed pointer outline.
|
|
3
|
+
|
|
4
|
+
export const POINTER_QUOTE_MAX = 240;
|
|
5
|
+
|
|
6
|
+
export type PointerGrab = {
|
|
7
|
+
blockId: string | null;
|
|
8
|
+
blockType: string | null;
|
|
9
|
+
blockIndex: number | null;
|
|
10
|
+
quote: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function quoteOf(target: Element): string {
|
|
14
|
+
return (target.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, POINTER_QUOTE_MAX);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function presentAttr(el: Element, name: string): string | null {
|
|
18
|
+
const value = el.getAttribute(name);
|
|
19
|
+
return value ? value : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Resolve the block marker and visible text under a click. Clicks on the bar are ignored. */
|
|
23
|
+
export function grabAt(target: Element | null, bar: Element | null): PointerGrab | null {
|
|
24
|
+
if (!target) return null;
|
|
25
|
+
const doc = target.ownerDocument;
|
|
26
|
+
if (target === doc.documentElement || target === doc.body) return null;
|
|
27
|
+
if (bar && (target === bar || bar.contains(target))) return null;
|
|
28
|
+
if (target.closest("[data-backmatter-pointer]")) return null;
|
|
29
|
+
|
|
30
|
+
const marker = target.closest("[data-backmatter-block]");
|
|
31
|
+
const quote = quoteOf(target);
|
|
32
|
+
if (!marker) {
|
|
33
|
+
if (!quote) return null;
|
|
34
|
+
return { blockId: null, blockType: null, blockIndex: null, quote };
|
|
35
|
+
}
|
|
36
|
+
const indexRaw = marker.getAttribute("data-backmatter-index");
|
|
37
|
+
const blockIndex = indexRaw !== null && /^\d+$/.test(indexRaw) ? Number(indexRaw) : null;
|
|
38
|
+
return {
|
|
39
|
+
blockId: presentAttr(marker, "data-backmatter-block"),
|
|
40
|
+
blockType: presentAttr(marker, "data-backmatter-type"),
|
|
41
|
+
blockIndex,
|
|
42
|
+
quote,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type SitePointer = {
|
|
47
|
+
arm: () => void;
|
|
48
|
+
disarm: () => void;
|
|
49
|
+
dispose: () => void;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* While armed, the page shows a crosshair and an outline. A click reports the
|
|
54
|
+
* element and does not follow links. Escape disarms. The picker stays armed
|
|
55
|
+
* after a click so another element can be added.
|
|
56
|
+
*/
|
|
57
|
+
export function installSitePointer(
|
|
58
|
+
doc: Document,
|
|
59
|
+
options: {
|
|
60
|
+
bar: () => Element | null;
|
|
61
|
+
ignore?: (target: EventTarget | null) => boolean;
|
|
62
|
+
onGrab: (grab: PointerGrab) => void;
|
|
63
|
+
onEscape?: () => void;
|
|
64
|
+
},
|
|
65
|
+
): SitePointer {
|
|
66
|
+
let armed = false;
|
|
67
|
+
let current: Element | null = null;
|
|
68
|
+
let outline: HTMLDivElement | null = null;
|
|
69
|
+
const root = doc.documentElement;
|
|
70
|
+
|
|
71
|
+
function ignored(target: EventTarget | null): boolean {
|
|
72
|
+
if (!(target instanceof Element)) return true;
|
|
73
|
+
if (target === root || target === doc.body) return true;
|
|
74
|
+
if (target.closest("[data-backmatter-pointer]")) return true;
|
|
75
|
+
const bar = options.bar();
|
|
76
|
+
if (bar && (target === bar || bar.contains(target))) return true;
|
|
77
|
+
return options.ignore?.(target) ?? false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function ensureOutline(): HTMLDivElement {
|
|
81
|
+
if (outline?.isConnected) return outline;
|
|
82
|
+
outline?.remove();
|
|
83
|
+
outline = null;
|
|
84
|
+
const el = doc.createElement("div");
|
|
85
|
+
el.setAttribute("data-backmatter-pointer", "");
|
|
86
|
+
el.style.position = "fixed";
|
|
87
|
+
el.style.pointerEvents = "none";
|
|
88
|
+
el.style.zIndex = "2147482980";
|
|
89
|
+
el.style.boxSizing = "border-box";
|
|
90
|
+
el.style.border = "1.5px solid #18181b";
|
|
91
|
+
el.style.borderRadius = "4px";
|
|
92
|
+
el.style.background = "rgba(24, 24, 27, 0.06)";
|
|
93
|
+
const label = doc.createElement("div");
|
|
94
|
+
label.setAttribute("data-backmatter-pointer-label", "");
|
|
95
|
+
label.style.position = "absolute";
|
|
96
|
+
label.style.left = "0";
|
|
97
|
+
label.style.top = "0";
|
|
98
|
+
label.style.maxWidth = "240px";
|
|
99
|
+
label.style.overflow = "hidden";
|
|
100
|
+
label.style.textOverflow = "ellipsis";
|
|
101
|
+
label.style.whiteSpace = "nowrap";
|
|
102
|
+
label.style.padding = "2px 6px";
|
|
103
|
+
label.style.borderRadius = "4px";
|
|
104
|
+
label.style.background = "#18181b";
|
|
105
|
+
label.style.color = "#ffffff";
|
|
106
|
+
label.style.font = '11px/1.4 ui-sans-serif, system-ui, sans-serif';
|
|
107
|
+
label.style.display = "none";
|
|
108
|
+
el.appendChild(label);
|
|
109
|
+
doc.body.appendChild(el);
|
|
110
|
+
outline = el;
|
|
111
|
+
return el;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function place(target: Element | null) {
|
|
115
|
+
current = target;
|
|
116
|
+
if (!target) {
|
|
117
|
+
if (outline) outline.style.display = "none";
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const el = ensureOutline();
|
|
121
|
+
const rect = target.getBoundingClientRect();
|
|
122
|
+
if (rect.width < 1 && rect.height < 1) {
|
|
123
|
+
el.style.display = "none";
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
el.style.display = "block";
|
|
127
|
+
el.style.left = `${rect.left}px`;
|
|
128
|
+
el.style.top = `${rect.top}px`;
|
|
129
|
+
el.style.width = `${rect.width}px`;
|
|
130
|
+
el.style.height = `${rect.height}px`;
|
|
131
|
+
const label = el.firstElementChild as HTMLElement | null;
|
|
132
|
+
const name = target.closest("[data-backmatter-block]")?.getAttribute("data-backmatter-type") ?? "";
|
|
133
|
+
if (!label) return;
|
|
134
|
+
if (!name) {
|
|
135
|
+
label.textContent = "";
|
|
136
|
+
label.style.display = "none";
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
label.textContent = name;
|
|
140
|
+
label.style.display = "block";
|
|
141
|
+
label.style.transform = rect.top < 28 ? "none" : "translateY(calc(-100% - 4px))";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function onMove(event: PointerEvent) {
|
|
145
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
146
|
+
place(target && !ignored(target) ? target : null);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function onScroll() {
|
|
150
|
+
if (current && !ignored(current)) place(current);
|
|
151
|
+
else place(null);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function onClick(event: MouseEvent) {
|
|
155
|
+
if (!armed || ignored(event.target)) return;
|
|
156
|
+
event.preventDefault();
|
|
157
|
+
event.stopPropagation();
|
|
158
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
159
|
+
const grab = grabAt(target, options.bar());
|
|
160
|
+
if (grab) options.onGrab(grab);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function onKey(event: KeyboardEvent) {
|
|
164
|
+
if (!armed || event.key !== "Escape") return;
|
|
165
|
+
event.preventDefault();
|
|
166
|
+
event.stopPropagation();
|
|
167
|
+
disarm();
|
|
168
|
+
options.onEscape?.();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function arm() {
|
|
172
|
+
if (armed) return;
|
|
173
|
+
armed = true;
|
|
174
|
+
root.classList.add("backmatter-pointing");
|
|
175
|
+
doc.addEventListener("pointermove", onMove, true);
|
|
176
|
+
doc.addEventListener("scroll", onScroll, true);
|
|
177
|
+
doc.addEventListener("click", onClick, true);
|
|
178
|
+
doc.addEventListener("keydown", onKey, true);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function disarm() {
|
|
182
|
+
if (!armed) return;
|
|
183
|
+
armed = false;
|
|
184
|
+
current = null;
|
|
185
|
+
root.classList.remove("backmatter-pointing");
|
|
186
|
+
doc.removeEventListener("pointermove", onMove, true);
|
|
187
|
+
doc.removeEventListener("scroll", onScroll, true);
|
|
188
|
+
doc.removeEventListener("click", onClick, true);
|
|
189
|
+
doc.removeEventListener("keydown", onKey, true);
|
|
190
|
+
outline?.remove();
|
|
191
|
+
outline = null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
arm,
|
|
196
|
+
disarm,
|
|
197
|
+
dispose: disarm,
|
|
198
|
+
};
|
|
199
|
+
}
|
package/src/studio-client.tsx
CHANGED
|
@@ -24,6 +24,32 @@ export type StudioMountProps = {
|
|
|
24
24
|
|
|
25
25
|
type SiteStudioComponent = ComponentType<StudioMountProps>;
|
|
26
26
|
|
|
27
|
+
const fallbackStyle = {
|
|
28
|
+
minHeight: "100dvh",
|
|
29
|
+
display: "grid",
|
|
30
|
+
placeItems: "center",
|
|
31
|
+
fontFamily: "ui-sans-serif, system-ui, sans-serif",
|
|
32
|
+
color: "#71717a",
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
const SESSION_EXPIRED_MESSAGE = "backmatter:session-expired";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Rendered instead of redirecting to the dashboard. A frame tells the parent
|
|
39
|
+
* to drop the bar. The top window goes to `enterUrl` to sign in.
|
|
40
|
+
*/
|
|
41
|
+
export function StudioSignedOut({ enterUrl }: { enterUrl: string }) {
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const framed = Boolean(window.top && window.top !== window);
|
|
44
|
+
if (framed) {
|
|
45
|
+
window.parent.postMessage({ type: SESSION_EXPIRED_MESSAGE }, window.location.origin);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
window.location.assign(enterUrl);
|
|
49
|
+
}, [enterUrl]);
|
|
50
|
+
return <div style={fallbackStyle} aria-busy="true" />;
|
|
51
|
+
}
|
|
52
|
+
|
|
27
53
|
export function StudioMount(props: StudioMountProps) {
|
|
28
54
|
const [Studio, setStudio] = useState<SiteStudioComponent | null>(null);
|
|
29
55
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -52,11 +78,3 @@ export function StudioMount(props: StudioMountProps) {
|
|
|
52
78
|
if (!Studio) return <div style={fallbackStyle} aria-busy="true" />;
|
|
53
79
|
return <Studio {...props} />;
|
|
54
80
|
}
|
|
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
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
// export default createStudioPage(cms);
|
|
11
11
|
//
|
|
12
12
|
// The Studio talks to `/api/backmatter/cms/*` on this origin with the site
|
|
13
|
-
// session cookie. Without a session the page
|
|
14
|
-
// dashboard `enter` route
|
|
13
|
+
// session cookie. Without a session, a frame tells the parent page and a
|
|
14
|
+
// top-level visit goes to the dashboard `enter` route.
|
|
15
15
|
import { Suspense, type ReactNode } from "react";
|
|
16
16
|
import {
|
|
17
17
|
PREVIEW_SESSION_COOKIE,
|
|
@@ -23,7 +23,7 @@ import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
|
|
|
23
23
|
import studioPkg from "@backmatter/studio/package.json";
|
|
24
24
|
import type { StudioDefinition } from "@backmatter/studio/define";
|
|
25
25
|
import { requestUrlFrom } from "./request-url";
|
|
26
|
-
import { StudioMount } from "./studio-client";
|
|
26
|
+
import { StudioMount, StudioSignedOut } from "./studio-client";
|
|
27
27
|
|
|
28
28
|
export { requestUrlFrom };
|
|
29
29
|
|
|
@@ -50,21 +50,22 @@ type NextHeaders = {
|
|
|
50
50
|
headers: () => Promise<{ get: (name: string) => string | null }>;
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
-
type NextNavigation = { redirect: (url: string) => never };
|
|
54
|
-
|
|
55
53
|
async function nextHeaders(): Promise<NextHeaders> {
|
|
56
54
|
return (await import("next/headers" as string)) as NextHeaders;
|
|
57
55
|
}
|
|
58
56
|
|
|
59
|
-
async function nextNavigation(): Promise<NextNavigation> {
|
|
60
|
-
return (await import("next/navigation" as string)) as NextNavigation;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
57
|
export function studioPathFor(basePath: string, segments: string[] | undefined, search = ""): string {
|
|
64
58
|
const rest = segments && segments.length > 0 ? `/${segments.map(encodeURIComponent).join("/")}` : "";
|
|
65
59
|
return `${basePath}${rest}${search}`;
|
|
66
60
|
}
|
|
67
61
|
|
|
62
|
+
type StudioPageProps = {
|
|
63
|
+
params: Promise<{ path?: string[] }> | { path?: string[] };
|
|
64
|
+
searchParams?:
|
|
65
|
+
| Promise<Record<string, string | string[] | undefined>>
|
|
66
|
+
| Record<string, string | string[] | undefined>;
|
|
67
|
+
};
|
|
68
|
+
|
|
68
69
|
export function createStudioPage(
|
|
69
70
|
cms: Pick<BackmatterClient, "config">,
|
|
70
71
|
options: StudioPageOptions = {},
|
|
@@ -73,14 +74,18 @@ export function createStudioPage(
|
|
|
73
74
|
const proxyPath = options.proxyPath ?? "/api/backmatter/cms";
|
|
74
75
|
const previewEntryPath = options.previewEntryPath ?? "/api/backmatter/preview";
|
|
75
76
|
|
|
76
|
-
|
|
77
|
-
params: Promise<{ path?: string[] }> | { path?: string[] };
|
|
78
|
-
searchParams?: Promise<Record<string, string | string[] | undefined>> | Record<string, string | string[] | undefined>;
|
|
79
|
-
}) {
|
|
77
|
+
function requireWorkspaceId(): string {
|
|
80
78
|
const workspaceId = options.workspaceId ?? cms.config.workspaceId;
|
|
81
79
|
if (!workspaceId) {
|
|
82
80
|
throw new Error("backmatter: the Studio needs a workspaceId (backmatter.config.ts or BACKMATTER_WORKSPACE_ID)");
|
|
83
81
|
}
|
|
82
|
+
return workspaceId;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Cookie and header reads stay in this child. The page returns Suspense
|
|
86
|
+
// first so Cache Components can prerender the route shell.
|
|
87
|
+
async function StudioSession(props: StudioPageProps & { workspaceId: string }) {
|
|
88
|
+
const { workspaceId } = props;
|
|
84
89
|
const dashboardUrl = resolveDashboardUrl(options.dashboardUrl);
|
|
85
90
|
const { cookies, headers } = await nextHeaders();
|
|
86
91
|
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
|
|
@@ -102,8 +107,7 @@ export function createStudioPage(
|
|
|
102
107
|
(name) => headerStore.get(name),
|
|
103
108
|
studioPathFor(basePath, params.path, searchText),
|
|
104
109
|
);
|
|
105
|
-
|
|
106
|
-
redirect(studioEnterUrl(dashboardUrl, { workspaceId, returnUrl }));
|
|
110
|
+
return <StudioSignedOut enterUrl={studioEnterUrl(dashboardUrl, { workspaceId, returnUrl })} />;
|
|
107
111
|
}
|
|
108
112
|
|
|
109
113
|
return (
|
|
@@ -118,6 +122,15 @@ export function createStudioPage(
|
|
|
118
122
|
studio={options.studio}
|
|
119
123
|
/>
|
|
120
124
|
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return function StudioPage(props: StudioPageProps) {
|
|
128
|
+
const workspaceId = requireWorkspaceId();
|
|
129
|
+
return (
|
|
130
|
+
<Suspense fallback={null}>
|
|
131
|
+
<StudioSession {...props} workspaceId={workspaceId} />
|
|
132
|
+
</Suspense>
|
|
133
|
+
);
|
|
121
134
|
};
|
|
122
135
|
}
|
|
123
136
|
|
|
@@ -133,7 +146,7 @@ export function StudioLayout({ children }: { children: ReactNode }) {
|
|
|
133
146
|
<script dangerouslySetInnerHTML={{ __html: THEME_BOOT }} />
|
|
134
147
|
</head>
|
|
135
148
|
<body style={{ margin: 0 }}>
|
|
136
|
-
{/* The page
|
|
149
|
+
{/* The page suspends on the session. This boundary keeps the document shell outside that hole. */}
|
|
137
150
|
<Suspense fallback={null}>{children}</Suspense>
|
|
138
151
|
</body>
|
|
139
152
|
</html>
|