@backmatter/next 0.0.20 → 0.0.22

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,341 @@
1
+ // packages/next/src/site-editor.tsx
2
+ "use client";
3
+
4
+ import { usePathname, useRouter } from "next/navigation";
5
+ import { startTransition, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
6
+ import { subscribeSiteDoc } from "./site-doc-bridge";
7
+ import { readSiteDoc, sameSiteDoc, type SiteDocRef } from "./site-doc";
8
+
9
+ function useSiteDoc(): SiteDocRef | null {
10
+ const pathname = usePathname();
11
+ const [doc, setDoc] = useState<SiteDocRef | null>(null);
12
+
13
+ useEffect(() => {
14
+ const apply = (next: SiteDocRef | null) => {
15
+ setDoc((prev) => (sameSiteDoc(prev, next) ? prev : next));
16
+ };
17
+ apply(readSiteDoc());
18
+ const stop = subscribeSiteDoc(apply);
19
+ const observer = new MutationObserver(() => apply(readSiteDoc()));
20
+ observer.observe(document.body, {
21
+ subtree: true,
22
+ childList: true,
23
+ attributes: true,
24
+ attributeFilter: ["data-locale", "data-type", "data-slug", "data-workspace"],
25
+ });
26
+ return () => {
27
+ stop();
28
+ observer.disconnect();
29
+ };
30
+ }, [pathname]);
31
+
32
+ return doc;
33
+ }
34
+
35
+ function dashboardOrigin(): string {
36
+ if (typeof document === "undefined") return "";
37
+ const marked = document.querySelector('meta[name="backmatter:dashboard"]')?.getAttribute("content");
38
+ if (marked) return marked.replace(/\/$/, "");
39
+ if (location.hostname === "localhost" || location.hostname === "127.0.0.1") {
40
+ return `${location.protocol}//${location.hostname}:5173`;
41
+ }
42
+ return "";
43
+ }
44
+
45
+ export function SiteChrome({
46
+ branch,
47
+ tree,
48
+ exiting,
49
+ onExit,
50
+ }: {
51
+ branch?: string;
52
+ tree?: string;
53
+ exiting?: boolean;
54
+ onExit: () => void;
55
+ }) {
56
+ const router = useRouter();
57
+ const doc = useSiteDoc();
58
+ const [editorMounted, setEditorMounted] = useState(false);
59
+ const [editorOpen, setEditorOpen] = useState(false);
60
+ const [chatMounted, setChatMounted] = useState(false);
61
+ const [chatOpen, setChatOpen] = useState(false);
62
+ const [publishOpen, setPublishOpen] = useState(false);
63
+ const [changeCount, setChangeCount] = useState(0);
64
+ const [origin, setOrigin] = useState("");
65
+ const editorFrame = useRef<HTMLIFrameElement>(null);
66
+
67
+ useEffect(() => {
68
+ setOrigin(dashboardOrigin());
69
+ }, []);
70
+
71
+ useEffect(() => {
72
+ function onKey(event: KeyboardEvent) {
73
+ if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "j") return;
74
+ event.preventDefault();
75
+ setChatMounted(true);
76
+ setChatOpen((open) => !open);
77
+ }
78
+ document.addEventListener("keydown", onKey, true);
79
+ return () => document.removeEventListener("keydown", onKey, true);
80
+ }, []);
81
+
82
+ useEffect(() => {
83
+ function onMessage(event: MessageEvent) {
84
+ if (event.origin !== origin) return;
85
+ const data = event.data as {
86
+ type?: string;
87
+ changeCount?: number;
88
+ key?: string;
89
+ metaKey?: boolean;
90
+ ctrlKey?: boolean;
91
+ };
92
+ if (data?.type === "backmatter:git" && typeof data.changeCount === "number") {
93
+ setChangeCount(data.changeCount);
94
+ }
95
+ if (
96
+ data?.type === "backmatter:hotkey" &&
97
+ data.key?.toLowerCase() === "j" &&
98
+ (data.metaKey || data.ctrlKey)
99
+ ) {
100
+ setChatMounted(true);
101
+ setChatOpen((open) => !open);
102
+ }
103
+ if (data?.type === "backmatter:published") {
104
+ setPublishOpen(false);
105
+ setChangeCount(0);
106
+ editorFrame.current?.contentWindow?.postMessage({ type: "backmatter:reload-doc" }, origin);
107
+ startTransition(() => router.refresh());
108
+ }
109
+ }
110
+ window.addEventListener("message", onMessage);
111
+ return () => window.removeEventListener("message", onMessage);
112
+ }, [origin, router]);
113
+
114
+ const label = doc ? `${doc.type}/${doc.slug}` : "No document";
115
+ const editorSrc =
116
+ doc && origin
117
+ ? `${origin}/workspaces/${doc.workspaceId}/docs/${doc.locale}/${doc.type}/${doc.slug}?embed=1`
118
+ : "";
119
+
120
+ function onChatWrote() {
121
+ editorFrame.current?.contentWindow?.postMessage({ type: "backmatter:reload-doc" }, origin);
122
+ startTransition(() => router.refresh());
123
+ }
124
+
125
+ return (
126
+ <>
127
+ <style>{"body{padding-bottom:36px}"}</style>
128
+ {editorMounted && doc && editorSrc ? (
129
+ <iframe
130
+ ref={editorFrame}
131
+ title={`Edit ${label}`}
132
+ src={editorSrc}
133
+ style={{
134
+ ...editorStyle,
135
+ visibility: editorOpen ? "visible" : "hidden",
136
+ pointerEvents: editorOpen ? "auto" : "none",
137
+ }}
138
+ />
139
+ ) : null}
140
+ {chatMounted && doc ? (
141
+ <CmsChat
142
+ doc={doc}
143
+ hidden={!chatOpen}
144
+ onWrote={onChatWrote}
145
+ onClosed={() => setChatOpen(false)}
146
+ />
147
+ ) : null}
148
+ {doc && origin ? (
149
+ <iframe
150
+ title="Publish"
151
+ src={`${origin}/embed/publish/${doc.workspaceId}`}
152
+ style={{
153
+ ...publishStyle,
154
+ visibility: publishOpen ? "visible" : "hidden",
155
+ pointerEvents: publishOpen ? "auto" : "none",
156
+ }}
157
+ />
158
+ ) : null}
159
+ <footer aria-label="Backmatter" style={barStyle}>
160
+ <button
161
+ type="button"
162
+ aria-pressed={editorOpen}
163
+ disabled={!doc}
164
+ onClick={() => {
165
+ setEditorMounted(true);
166
+ setEditorOpen((open) => !open);
167
+ }}
168
+ style={docButton}
169
+ >
170
+ {label}
171
+ </button>
172
+ <BarText>Preview</BarText>
173
+ {branch ? <BarText>{branch}</BarText> : null}
174
+ {tree ? <BarText>draft</BarText> : null}
175
+ <span style={{ flex: 1 }} />
176
+ <button type="button" aria-pressed={publishOpen} onClick={() => setPublishOpen((open) => !open)} style={barButton}>
177
+ Publish{changeCount ? ` ${changeCount}` : ""}
178
+ </button>
179
+ <button
180
+ type="button"
181
+ aria-pressed={chatOpen}
182
+ onClick={() => {
183
+ setChatMounted(true);
184
+ setChatOpen((open) => !open);
185
+ }}
186
+ title="Toggle AI chat (⌘J)"
187
+ style={barButton}
188
+ >
189
+ Agent
190
+ </button>
191
+ <button type="button" aria-label="Exit preview" disabled={exiting} onClick={onExit} style={barButton}>
192
+ Exit
193
+ </button>
194
+ </footer>
195
+ </>
196
+ );
197
+ }
198
+
199
+ function CmsChat({
200
+ doc,
201
+ hidden,
202
+ onWrote,
203
+ onClosed,
204
+ }: {
205
+ doc: SiteDocRef;
206
+ hidden: boolean;
207
+ onWrote: () => void;
208
+ onClosed: () => void;
209
+ }) {
210
+ const frame = useRef<HTMLIFrameElement>(null);
211
+ const onWroteRef = useRef(onWrote);
212
+ const onClosedRef = useRef(onClosed);
213
+ const hiddenRef = useRef(hidden);
214
+ onWroteRef.current = onWrote;
215
+ onClosedRef.current = onClosed;
216
+ hiddenRef.current = hidden;
217
+ const origin = dashboardOrigin();
218
+
219
+ function openChat() {
220
+ const frameEl = frame.current;
221
+ if (!frameEl) return;
222
+ frameEl.focus();
223
+ frameEl.contentWindow?.postMessage({ type: "backmatter:chat-open" }, origin);
224
+ }
225
+
226
+ useEffect(() => {
227
+ if (!hidden) openChat();
228
+ }, [hidden, origin]);
229
+
230
+ useEffect(() => {
231
+ const focus = { locale: doc.locale, type: doc.type, slug: doc.slug };
232
+ function postFocus() {
233
+ frame.current?.contentWindow?.postMessage({ type: "backmatter:focus", doc: focus }, origin);
234
+ }
235
+ function onMessage(event: MessageEvent) {
236
+ if (event.origin !== origin) return;
237
+ if (event.data?.type === "backmatter:chat-ready") {
238
+ postFocus();
239
+ if (!hiddenRef.current) openChat();
240
+ }
241
+ if (event.data?.type === "backmatter:doc-changed") onWroteRef.current();
242
+ if (event.data?.type === "backmatter:chat-closed") onClosedRef.current();
243
+ }
244
+ window.addEventListener("message", onMessage);
245
+ postFocus();
246
+ return () => window.removeEventListener("message", onMessage);
247
+ }, [doc.locale, doc.type, doc.slug, origin]);
248
+
249
+ if (!origin) return null;
250
+ return (
251
+ <iframe
252
+ ref={frame}
253
+ title="AI chat"
254
+ src={`${origin}/embed/chat/${doc.workspaceId}`}
255
+ style={{
256
+ ...chatStyle,
257
+ visibility: hidden ? "hidden" : "visible",
258
+ pointerEvents: hidden ? "none" : "auto",
259
+ }}
260
+ />
261
+ );
262
+ }
263
+
264
+ function BarText({ children }: { children: ReactNode }) {
265
+ return <span style={mutedStyle}>{children}</span>;
266
+ }
267
+
268
+ const barStyle: CSSProperties = {
269
+ position: "fixed",
270
+ left: 0,
271
+ right: 0,
272
+ bottom: 0,
273
+ zIndex: 2147483000,
274
+ display: "flex",
275
+ alignItems: "center",
276
+ gap: 12,
277
+ height: 36,
278
+ padding: "0 12px",
279
+ paddingBottom: "env(safe-area-inset-bottom, 0px)",
280
+ boxSizing: "border-box",
281
+ borderTop: "1px solid #e4e4e7",
282
+ background: "#f4f4f5",
283
+ color: "#71717a",
284
+ font: "12px/1 ui-sans-serif, system-ui, sans-serif",
285
+ };
286
+
287
+ const barButton: CSSProperties = {
288
+ border: 0,
289
+ background: "transparent",
290
+ color: "#18181b",
291
+ font: "inherit",
292
+ cursor: "pointer",
293
+ padding: "4px 6px",
294
+ };
295
+
296
+ const docButton: CSSProperties = {
297
+ ...barButton,
298
+ fontWeight: 600,
299
+ };
300
+
301
+ const mutedStyle: CSSProperties = { color: "#71717a", fontSize: 12, margin: 0 };
302
+
303
+ const editorStyle: CSSProperties = {
304
+ position: "fixed",
305
+ left: 0,
306
+ right: 0,
307
+ bottom: 36,
308
+ zIndex: 2147482999,
309
+ width: "100%",
310
+ height: "min(78vh, calc(100vh - 48px))",
311
+ border: 0,
312
+ borderTop: "1px solid #e4e4e7",
313
+ background: "#ffffff",
314
+ boxShadow: "0 -8px 24px rgba(24, 24, 27, 0.08)",
315
+ };
316
+
317
+ const chatStyle: CSSProperties = {
318
+ position: "fixed",
319
+ right: 12,
320
+ bottom: 48,
321
+ zIndex: 2147483001,
322
+ width: "min(440px, calc(100vw - 24px))",
323
+ height: "min(640px, calc(100vh - 120px))",
324
+ border: 0,
325
+ borderRadius: 14,
326
+ background: "#f4f4f5",
327
+ boxShadow: "0 16px 48px rgba(0,0,0,0.22), 0 2px 8px rgba(0,0,0,0.08)",
328
+ };
329
+
330
+ const publishStyle: CSSProperties = {
331
+ position: "fixed",
332
+ right: 12,
333
+ bottom: 48,
334
+ zIndex: 2147483001,
335
+ width: "min(420px, calc(100vw - 24px))",
336
+ height: "min(520px, calc(100vh - 120px))",
337
+ border: "1px solid #e4e4e7",
338
+ borderRadius: 8,
339
+ background: "#ffffff",
340
+ boxShadow: "0 12px 40px rgba(24, 24, 27, 0.16)",
341
+ };
@@ -0,0 +1,42 @@
1
+ // packages/next/src/source-data.ts
2
+ // The one place that decides how an external model's record is obtained for a
3
+ // route: `load(ref)` for a real slug, `sample()` for the template document.
4
+ // Both the catch-all and `renderPreview` call this; `renderLayoutDoc` only
5
+ // receives the result.
6
+ import { isTemplateSlug, type ModelDef, type SourceDef, type SourceRef } from "@backmatter/core";
7
+
8
+ export type SourceResult = { ok: true; data: unknown } | { ok: false };
9
+
10
+ /** Source for `model`, or throw when an external model has no `sources/{type}.ts`. */
11
+ export function requireSource(
12
+ sources: Record<string, SourceDef> | undefined,
13
+ model: ModelDef,
14
+ ): SourceDef | undefined {
15
+ const source = sources?.[model.type];
16
+ if (model.external && !source) {
17
+ throw new Error(
18
+ `[backmatter] model "${model.type}" is external but sources/${model.type}.ts was not found`,
19
+ );
20
+ }
21
+ return source;
22
+ }
23
+
24
+ /**
25
+ * `{ ok: false }` means the outside record does not exist and the route is a 404.
26
+ * Without a source `data` is `undefined`. On the template slug `data` is the
27
+ * sample record, or `null` when the source has no `sample`.
28
+ */
29
+ export async function resolveSourceData(
30
+ source: SourceDef | undefined,
31
+ model: ModelDef,
32
+ ref: SourceRef,
33
+ ): Promise<SourceResult> {
34
+ if (!source) return { ok: true, data: undefined };
35
+ if (isTemplateSlug(model, ref.slug)) {
36
+ const sample = source.sample ? await source.sample(ref.locale) : null;
37
+ return { ok: true, data: sample };
38
+ }
39
+ const record = await source.load(ref);
40
+ if (record === null || record === undefined) return { ok: false };
41
+ return { ok: true, data: record };
42
+ }