@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.
@@ -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
+ }
@@ -0,0 +1,80 @@
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
+ 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
+
53
+ export function StudioMount(props: StudioMountProps) {
54
+ const [Studio, setStudio] = useState<SiteStudioComponent | null>(null);
55
+ const [error, setError] = useState<string | null>(null);
56
+
57
+ useEffect(() => {
58
+ let cancelled = false;
59
+ import("@backmatter/studio")
60
+ .then((mod) => {
61
+ if (!cancelled) setStudio(() => mod.SiteStudio as SiteStudioComponent);
62
+ })
63
+ .catch((err: unknown) => {
64
+ if (!cancelled) setError(err instanceof Error ? err.message : "Could not load the Studio");
65
+ });
66
+ return () => {
67
+ cancelled = true;
68
+ };
69
+ }, []);
70
+
71
+ if (error) {
72
+ return (
73
+ <div style={fallbackStyle} role="alert">
74
+ {error}
75
+ </div>
76
+ );
77
+ }
78
+ if (!Studio) return <div style={fallbackStyle} aria-busy="true" />;
79
+ return <Studio {...props} />;
80
+ }
package/src/studio.tsx ADDED
@@ -0,0 +1,157 @@
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, a frame tells the parent page and a
14
+ // top-level visit goes to the dashboard `enter` route.
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, StudioSignedOut } 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
+ async function nextHeaders(): Promise<NextHeaders> {
54
+ return (await import("next/headers" as string)) as NextHeaders;
55
+ }
56
+
57
+ export function studioPathFor(basePath: string, segments: string[] | undefined, search = ""): string {
58
+ const rest = segments && segments.length > 0 ? `/${segments.map(encodeURIComponent).join("/")}` : "";
59
+ return `${basePath}${rest}${search}`;
60
+ }
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
+
69
+ export function createStudioPage(
70
+ cms: Pick<BackmatterClient, "config">,
71
+ options: StudioPageOptions = {},
72
+ ) {
73
+ const basePath = (options.basePath ?? "/backmatter").replace(/\/+$/, "") || "/backmatter";
74
+ const proxyPath = options.proxyPath ?? "/api/backmatter/cms";
75
+ const previewEntryPath = options.previewEntryPath ?? "/api/backmatter/preview";
76
+
77
+ function requireWorkspaceId(): string {
78
+ const workspaceId = options.workspaceId ?? cms.config.workspaceId;
79
+ if (!workspaceId) {
80
+ throw new Error("backmatter: the Studio needs a workspaceId (backmatter.config.ts or BACKMATTER_WORKSPACE_ID)");
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;
89
+ const dashboardUrl = resolveDashboardUrl(options.dashboardUrl);
90
+ const { cookies, headers } = await nextHeaders();
91
+ const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
92
+
93
+ const token = cookieStore.get(PREVIEW_SESSION_COOKIE)?.value;
94
+ const secret = cms.config.previewToken;
95
+ const claims = token && secret ? await readPreviewSession(secret, token) : null;
96
+ const valid = claims !== null && claims.workspaceId === workspaceId;
97
+
98
+ if (!valid) {
99
+ const params = await props.params;
100
+ const search = props.searchParams ? await props.searchParams : {};
101
+ const qs = new URLSearchParams();
102
+ for (const [key, value] of Object.entries(search)) {
103
+ if (typeof value === "string") qs.set(key, value);
104
+ }
105
+ const searchText = qs.size > 0 ? `?${qs.toString()}` : "";
106
+ const returnUrl = requestUrlFrom(
107
+ (name) => headerStore.get(name),
108
+ studioPathFor(basePath, params.path, searchText),
109
+ );
110
+ return <StudioSignedOut enterUrl={studioEnterUrl(dashboardUrl, { workspaceId, returnUrl })} />;
111
+ }
112
+
113
+ return (
114
+ <StudioMount
115
+ workspaceId={workspaceId}
116
+ basePath={basePath}
117
+ dashboardUrl={dashboardUrl}
118
+ version={studioPkg.version}
119
+ proxyPath={proxyPath}
120
+ previewEntryPath={previewEntryPath}
121
+ apiUrl={cms.config.apiUrl}
122
+ studio={options.studio}
123
+ />
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
+ );
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Root layout for the Studio route group. Its own `<html>` so the site's
139
+ * layout, fonts and global CSS do not wrap the editor.
140
+ */
141
+ export function StudioLayout({ children }: { children: ReactNode }) {
142
+ return (
143
+ <html lang="en" suppressHydrationWarning>
144
+ <head>
145
+ <meta name="robots" content="noindex, nofollow" />
146
+ <script dangerouslySetInnerHTML={{ __html: THEME_BOOT }} />
147
+ </head>
148
+ <body style={{ margin: 0 }}>
149
+ {/* The page suspends on the session. This boundary keeps the document shell outside that hole. */}
150
+ <Suspense fallback={null}>{children}</Suspense>
151
+ </body>
152
+ </html>
153
+ );
154
+ }
155
+
156
+ // Same first-paint theme pick as the dashboard's index.html.
157
+ 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){}})();`;