@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.
- package/dist/config.cjs +71 -6
- package/dist/loader.cjs +71 -5
- package/package.json +5 -2
- package/src/cms-proxy.ts +108 -0
- package/src/config.ts +7 -1
- package/src/devbar-client.tsx +29 -14
- package/src/layout-doc.ts +38 -0
- package/src/loader.ts +6 -2
- package/src/page-render.ts +7 -3
- package/src/preview-content.ts +49 -0
- package/src/preview-live.tsx +9 -0
- package/src/preview-refresh.tsx +220 -3
- package/src/preview-route.ts +51 -21
- package/src/render-preview.ts +186 -0
- package/src/revalidate.ts +2 -2
- package/src/routes.ts +207 -45
- package/src/site-channel.ts +37 -0
- package/src/site-doc-bridge.tsx +50 -0
- package/src/site-doc.ts +123 -0
- package/src/site-editor.tsx +341 -0
- package/src/source-data.ts +42 -0
|
@@ -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
|
+
}
|