@backmatter/next 0.0.21 → 0.0.23
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 +12 -5
- package/src/cms-proxy.ts +166 -0
- package/src/config.ts +7 -1
- package/src/css.d.ts +2 -0
- package/src/devbar-client.tsx +136 -17
- package/src/devbar.tsx +20 -3
- package/src/gate.ts +85 -0
- 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 +211 -3
- package/src/preview-route.ts +90 -21
- package/src/render-preview.ts +186 -0
- package/src/request-url.ts +13 -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 +353 -0
- package/src/source-data.ts +42 -0
- package/src/studio-client.tsx +62 -0
- package/src/studio.tsx +144 -0
package/src/devbar-client.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// packages/next/src/devbar-client.tsx
|
|
2
2
|
"use client";
|
|
3
3
|
|
|
4
|
-
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
|
|
4
|
+
import { Suspense, lazy, useEffect, useState, type CSSProperties, type ReactNode } from "react";
|
|
5
5
|
import {
|
|
6
6
|
clearHostPreviewSession,
|
|
7
7
|
fetchCmsSession,
|
|
@@ -9,6 +9,18 @@ import {
|
|
|
9
9
|
resolveDevMode,
|
|
10
10
|
type CmsSessionUser,
|
|
11
11
|
} from "@backmatter/core/preview";
|
|
12
|
+
import {
|
|
13
|
+
ENTER_ATTEMPT_KEY,
|
|
14
|
+
enterPath,
|
|
15
|
+
gateOpen,
|
|
16
|
+
isGateShortcut,
|
|
17
|
+
markerCookie,
|
|
18
|
+
recentEnterAttempt,
|
|
19
|
+
stripOptInParam,
|
|
20
|
+
} from "./gate";
|
|
21
|
+
|
|
22
|
+
/** The bar and its panels load only after the gate opens. */
|
|
23
|
+
const SiteChrome = lazy(() => import("./site-editor").then((m) => ({ default: m.SiteChrome })));
|
|
12
24
|
|
|
13
25
|
export type { CmsSessionUser };
|
|
14
26
|
|
|
@@ -80,7 +92,7 @@ const barStyle: CSSProperties = {
|
|
|
80
92
|
alignItems: "center",
|
|
81
93
|
gap: 10,
|
|
82
94
|
minHeight: 32,
|
|
83
|
-
maxWidth: "min(
|
|
95
|
+
maxWidth: "min(520px, calc(100vw - 24px))",
|
|
84
96
|
padding: "0 12px",
|
|
85
97
|
paddingBottom: "env(safe-area-inset-bottom, 0px)",
|
|
86
98
|
background: "#f4f4f5",
|
|
@@ -187,10 +199,12 @@ function Divider() {
|
|
|
187
199
|
export function DevBarView({
|
|
188
200
|
state,
|
|
189
201
|
onExit,
|
|
202
|
+
onEdit,
|
|
190
203
|
exiting,
|
|
191
204
|
}: {
|
|
192
205
|
state: DevModeState;
|
|
193
206
|
onExit?: () => void;
|
|
207
|
+
onEdit?: () => void;
|
|
194
208
|
exiting?: boolean;
|
|
195
209
|
}) {
|
|
196
210
|
if (state.status !== "ready") return null;
|
|
@@ -240,6 +254,20 @@ export function DevBarView({
|
|
|
240
254
|
<Segment>draft</Segment>
|
|
241
255
|
</>
|
|
242
256
|
) : null}
|
|
257
|
+
{!live && onEdit ? (
|
|
258
|
+
<>
|
|
259
|
+
<Divider />
|
|
260
|
+
<button
|
|
261
|
+
type="button"
|
|
262
|
+
className="backmatter-devbar-exit"
|
|
263
|
+
aria-label="Edit this page"
|
|
264
|
+
onClick={() => onEdit()}
|
|
265
|
+
style={exitButtonStyle}
|
|
266
|
+
>
|
|
267
|
+
Edit
|
|
268
|
+
</button>
|
|
269
|
+
</>
|
|
270
|
+
) : null}
|
|
243
271
|
{!live ? (
|
|
244
272
|
<>
|
|
245
273
|
<Divider />
|
|
@@ -259,22 +287,113 @@ export function DevBarView({
|
|
|
259
287
|
);
|
|
260
288
|
}
|
|
261
289
|
|
|
262
|
-
|
|
263
|
-
|
|
290
|
+
const DEFAULT_PREVIEW_ENTRY_PATH = "/api/backmatter/preview";
|
|
291
|
+
|
|
292
|
+
type Gate = "closed" | "open";
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Opt-in check with no network: `?backmatter=1` (then stripped from the URL and
|
|
296
|
+
* remembered in the marker cookie), the marker itself, or the desktop
|
|
297
|
+
* shortcut. Public visitors stop here.
|
|
298
|
+
*/
|
|
299
|
+
function useGate(): Gate {
|
|
300
|
+
const [gate, setGate] = useState<Gate>("closed");
|
|
301
|
+
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
// Framed by an editor (the Studio's split preview, the dashboard's split
|
|
304
|
+
// view): that editor is the chrome, so the page shows no bar of its own.
|
|
305
|
+
if (window.self !== window.top) return;
|
|
306
|
+
const secure = location.protocol === "https:";
|
|
307
|
+
if (gateOpen({ search: location.search, cookie: document.cookie })) {
|
|
308
|
+
document.cookie = markerCookie(true, secure);
|
|
309
|
+
const clean = stripOptInParam(location.href);
|
|
310
|
+
if (clean) history.replaceState(history.state, "", clean);
|
|
311
|
+
setGate("open");
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const onKey = (e: KeyboardEvent) => {
|
|
315
|
+
if (!isGateShortcut(e)) return;
|
|
316
|
+
e.preventDefault();
|
|
317
|
+
document.cookie = markerCookie(true, secure);
|
|
318
|
+
setGate("open");
|
|
319
|
+
};
|
|
320
|
+
window.addEventListener("keydown", onKey);
|
|
321
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
322
|
+
}, []);
|
|
323
|
+
|
|
324
|
+
return gate;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Past the gate: ask this origin for the session. None means a top-level
|
|
329
|
+
* bounce to the dashboard, which returns here with a ticket. A return without
|
|
330
|
+
* a session (login abandoned, site not in the workspace) clears the marker
|
|
331
|
+
* instead of bouncing again.
|
|
332
|
+
*/
|
|
333
|
+
function useSiteSession(gate: Gate, previewEntryPath: string): DevModeState {
|
|
334
|
+
const [state, setState] = useState<DevModeState>({ status: "hidden" });
|
|
335
|
+
|
|
336
|
+
useEffect(() => {
|
|
337
|
+
if (gate !== "open") return;
|
|
338
|
+
let cancelled = false;
|
|
339
|
+
void loadDevModeState("").then((next) => {
|
|
340
|
+
if (cancelled) return;
|
|
341
|
+
if (next.status === "ready" && next.mode === "preview") {
|
|
342
|
+
sessionStorage.removeItem(ENTER_ATTEMPT_KEY);
|
|
343
|
+
setState(next);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (recentEnterAttempt(sessionStorage.getItem(ENTER_ATTEMPT_KEY), Date.now())) {
|
|
347
|
+
sessionStorage.removeItem(ENTER_ATTEMPT_KEY);
|
|
348
|
+
document.cookie = markerCookie(false, location.protocol === "https:");
|
|
349
|
+
setState({ status: "hidden" });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
sessionStorage.setItem(ENTER_ATTEMPT_KEY, String(Date.now()));
|
|
353
|
+
location.assign(enterPath(previewEntryPath, location.pathname + location.search + location.hash));
|
|
354
|
+
});
|
|
355
|
+
return () => {
|
|
356
|
+
cancelled = true;
|
|
357
|
+
};
|
|
358
|
+
}, [gate, previewEntryPath]);
|
|
359
|
+
|
|
360
|
+
return state;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function DevBarClient({
|
|
364
|
+
studioPath,
|
|
365
|
+
previewEntryPath = DEFAULT_PREVIEW_ENTRY_PATH,
|
|
366
|
+
}: {
|
|
367
|
+
/** Unused since the bar stopped calling the CMS origin; kept for host layouts that still pass it. */
|
|
368
|
+
apiUrl?: string;
|
|
369
|
+
studioPath?: string;
|
|
370
|
+
previewEntryPath?: string;
|
|
371
|
+
}) {
|
|
372
|
+
const gate = useGate();
|
|
373
|
+
const state = useSiteSession(gate, previewEntryPath);
|
|
264
374
|
const [exiting, setExiting] = useState(false);
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
|
|
375
|
+
const exit = () => {
|
|
376
|
+
if (exiting) return;
|
|
377
|
+
setExiting(true);
|
|
378
|
+
void exitHostPreview().then((ok) => {
|
|
379
|
+
if (!ok) setExiting(false);
|
|
380
|
+
});
|
|
381
|
+
};
|
|
382
|
+
if (state.status !== "ready") return null;
|
|
383
|
+
if (state.mode === "preview") {
|
|
384
|
+
return (
|
|
385
|
+
<Suspense fallback={null}>
|
|
386
|
+
<SiteChrome
|
|
387
|
+
branch={state.branch}
|
|
388
|
+
tree={state.tree}
|
|
389
|
+
exiting={exiting}
|
|
390
|
+
onExit={exit}
|
|
391
|
+
studioPath={studioPath}
|
|
392
|
+
/>
|
|
393
|
+
</Suspense>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
return <DevBarView state={state} exiting={exiting} onExit={exit} />;
|
|
278
397
|
}
|
|
279
398
|
|
|
280
399
|
export async function exitHostPreview(
|
package/src/devbar.tsx
CHANGED
|
@@ -13,7 +13,24 @@ export {
|
|
|
13
13
|
type DevModeState,
|
|
14
14
|
} from "./devbar-client";
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Host layout: `<DevBar />`. Public visitors get a gate of a few hundred
|
|
18
|
+
* bytes that makes no request; `?backmatter=1`, the `bm_ui` marker cookie or
|
|
19
|
+
* ⌘⇧E opens it. `studioPath` is where `@backmatter/studio` is mounted on this
|
|
20
|
+
* site (default `/backmatter`); the bar's panels are frames of that route.
|
|
21
|
+
* `previewEntryPath` is the ticket route (default `/api/backmatter/preview`).
|
|
22
|
+
* `apiUrl` is accepted for older layouts and no longer used.
|
|
23
|
+
*/
|
|
24
|
+
export function DevBar({
|
|
25
|
+
apiUrl,
|
|
26
|
+
studioPath,
|
|
27
|
+
previewEntryPath,
|
|
28
|
+
}: { apiUrl?: string; studioPath?: string; previewEntryPath?: string } = {}) {
|
|
29
|
+
return (
|
|
30
|
+
<DevBarClient
|
|
31
|
+
apiUrl={resolveDevBarApiUrl(apiUrl)}
|
|
32
|
+
studioPath={studioPath}
|
|
33
|
+
previewEntryPath={previewEntryPath}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
19
36
|
}
|
package/src/gate.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// packages/next/src/gate.ts
|
|
2
|
+
// The visitor gate in front of the editing bar. Pure helpers so the decision
|
|
3
|
+
// is testable without a DOM: a page opens the bar only when the visitor opted
|
|
4
|
+
// in through `?backmatter=1`, carries the marker cookie `bm_ui=1`, or presses
|
|
5
|
+
// the desktop shortcut. Anyone else gets no fetch and no editor code.
|
|
6
|
+
|
|
7
|
+
export const MARKER_COOKIE = "bm_ui";
|
|
8
|
+
export const OPT_IN_PARAM = "backmatter";
|
|
9
|
+
/** 30 days, per origin. Not HttpOnly: the gate reads it before any request. */
|
|
10
|
+
export const MARKER_TTL_S = 30 * 24 * 60 * 60;
|
|
11
|
+
/** Written when the gate bounces to the dashboard; read on return to stop a loop. */
|
|
12
|
+
export const ENTER_ATTEMPT_KEY = "backmatter:enter";
|
|
13
|
+
export const ENTER_ATTEMPT_WINDOW_MS = 60_000;
|
|
14
|
+
|
|
15
|
+
export function hasMarker(cookieHeader: string | null | undefined): boolean {
|
|
16
|
+
if (!cookieHeader) return false;
|
|
17
|
+
for (const part of cookieHeader.split(";")) {
|
|
18
|
+
const [key, ...rest] = part.trim().split("=");
|
|
19
|
+
if (key === MARKER_COOKIE) return rest.join("=") === "1";
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function hasOptInParam(search: string): boolean {
|
|
25
|
+
return new URLSearchParams(search).get(OPT_IN_PARAM) === "1";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function gateOpen(input: { search: string; cookie: string | null | undefined }): boolean {
|
|
29
|
+
return hasOptInParam(input.search) || hasMarker(input.cookie);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `document.cookie` assignment that sets or clears the marker. */
|
|
33
|
+
export function markerCookie(on: boolean, secure = false): string {
|
|
34
|
+
const parts = [
|
|
35
|
+
`${MARKER_COOKIE}=${on ? "1" : ""}`,
|
|
36
|
+
"Path=/",
|
|
37
|
+
`Max-Age=${on ? MARKER_TTL_S : 0}`,
|
|
38
|
+
"SameSite=Lax",
|
|
39
|
+
];
|
|
40
|
+
if (secure) parts.push("Secure");
|
|
41
|
+
return parts.join("; ");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The same href without `?backmatter=1`, or null when the param was absent. */
|
|
45
|
+
export function stripOptInParam(href: string): string | null {
|
|
46
|
+
const url = new URL(href);
|
|
47
|
+
if (url.searchParams.get(OPT_IN_PARAM) !== "1") return null;
|
|
48
|
+
url.searchParams.delete(OPT_IN_PARAM);
|
|
49
|
+
return url.toString();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** ⌘⇧E on macOS, Ctrl+Shift+E elsewhere. Not bound by the major browsers. */
|
|
53
|
+
export function isGateShortcut(e: {
|
|
54
|
+
key: string;
|
|
55
|
+
metaKey: boolean;
|
|
56
|
+
ctrlKey: boolean;
|
|
57
|
+
shiftKey: boolean;
|
|
58
|
+
altKey: boolean;
|
|
59
|
+
}): boolean {
|
|
60
|
+
if (!e.shiftKey || e.altKey) return false;
|
|
61
|
+
if (!(e.metaKey || e.ctrlKey)) return false;
|
|
62
|
+
return e.key === "E" || e.key === "e";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Same-origin path only: must start with one `/`. Anything else goes home. */
|
|
66
|
+
export function safeReturnPath(raw: string | null | undefined): string {
|
|
67
|
+
const value = raw?.trim() ?? "";
|
|
68
|
+
if (!value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\")) return "/";
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The host route that bounces to the dashboard `enter` and comes back to `returnPath`. */
|
|
73
|
+
export function enterPath(previewEntryPath: string, returnPath: string): string {
|
|
74
|
+
return `${previewEntryPath}?enter=1&return=${encodeURIComponent(safeReturnPath(returnPath))}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Loop guard: true when a bounce started within the window, meaning we came
|
|
79
|
+
* back from the dashboard without a session. The caller then gives up and
|
|
80
|
+
* clears the marker instead of bouncing again.
|
|
81
|
+
*/
|
|
82
|
+
export function recentEnterAttempt(stored: string | null, now: number): boolean {
|
|
83
|
+
const at = Number(stored);
|
|
84
|
+
return Number.isFinite(at) && at > 0 && now - at < ENTER_ATTEMPT_WINDOW_MS;
|
|
85
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// packages/next/src/layout-doc.ts
|
|
2
|
+
// Shared body render for the catch-all and `renderPreview`. No preview chrome.
|
|
3
|
+
// `data` is the external source record already resolved by the caller
|
|
4
|
+
// (see source-data.ts); this module only threads it to blocks and the layout.
|
|
5
|
+
import { createElement, type ReactNode } from "react";
|
|
6
|
+
import { modelFormat, type BackmatterDoc, type ModelDef, type SourceDef } from "@backmatter/core";
|
|
7
|
+
import { renderDoc, type MdxComponents } from "./mdx-compile";
|
|
8
|
+
import { renderPage, type BlockComponents, type BlockFallbacks } from "./page-render";
|
|
9
|
+
|
|
10
|
+
export type DocLayout = (doc: BackmatterDoc, body: ReactNode, data?: unknown) => ReactNode;
|
|
11
|
+
|
|
12
|
+
export type LayoutDocOptions = {
|
|
13
|
+
models: readonly ModelDef[];
|
|
14
|
+
components?: MdxComponents;
|
|
15
|
+
blocks?: BlockComponents;
|
|
16
|
+
fallbacks?: BlockFallbacks;
|
|
17
|
+
layouts?: Record<string, DocLayout>;
|
|
18
|
+
/** `sources/{type}.ts` loaders, usually the generated `sources`. Resolved by the caller. */
|
|
19
|
+
sources?: Record<string, SourceDef>;
|
|
20
|
+
render?: (doc: BackmatterDoc, body: ReactNode) => ReactNode;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export async function renderLayoutDoc(
|
|
24
|
+
doc: BackmatterDoc,
|
|
25
|
+
options: LayoutDocOptions,
|
|
26
|
+
data?: unknown,
|
|
27
|
+
): Promise<ReactNode> {
|
|
28
|
+
const model = options.models.find((item) => item.type === doc.type);
|
|
29
|
+
const isPage = Boolean(model && modelFormat(model) === "page");
|
|
30
|
+
const rendered = isPage
|
|
31
|
+
? await renderPage(doc, options.blocks, options.fallbacks, data)
|
|
32
|
+
: await renderDoc(doc, { components: options.components });
|
|
33
|
+
const layout = options.layouts?.[doc.type];
|
|
34
|
+
const laidOut = layout ? layout(doc, rendered, data) : rendered;
|
|
35
|
+
if (options.render) return options.render(doc, laidOut);
|
|
36
|
+
if (layout || isPage) return laidOut;
|
|
37
|
+
return createElement("article", null, laidOut);
|
|
38
|
+
}
|
package/src/loader.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// packages/next/src/loader.ts
|
|
2
2
|
// Bundler loader for `.backmatter/index.ts`, `.backmatter/blocks.ts`,
|
|
3
|
-
// `.backmatter/mdx.ts`, `.backmatter/richtext.ts`, and `.backmatter/
|
|
3
|
+
// `.backmatter/mdx.ts`, `.backmatter/richtext.ts`, `.backmatter/layouts.ts`, and `.backmatter/sources.ts`.
|
|
4
4
|
//
|
|
5
5
|
// This is the Tailwind-JIT mechanism: instead of running our own watcher we
|
|
6
6
|
// tell the bundler that these modules depend on the `models/`, `blocks/`,
|
|
7
|
-
// `richtext/`, `mdx/`, and `
|
|
7
|
+
// `richtext/`, `mdx/`, `layouts/`, and `sources/` directories. webpack and Turbopack already watch the filesystem;
|
|
8
8
|
// when a file is added, removed or renamed there they invalidate the module,
|
|
9
9
|
// re-run this loader, and we regenerate the barrel from a fresh `readdir`.
|
|
10
10
|
import path from "node:path";
|
|
@@ -19,6 +19,7 @@ export type BackmatterLoaderOptions = {
|
|
|
19
19
|
mdxDir: string;
|
|
20
20
|
layoutsDir: string;
|
|
21
21
|
objectsDir: string;
|
|
22
|
+
sourcesDir: string;
|
|
22
23
|
sdk: string;
|
|
23
24
|
};
|
|
24
25
|
|
|
@@ -40,6 +41,7 @@ export default function backmatterLoader(this: BackmatterLoaderContext, source:
|
|
|
40
41
|
const mdxDir = path.resolve(root, options.mdxDir);
|
|
41
42
|
const layoutsDir = path.resolve(root, options.layoutsDir);
|
|
42
43
|
const objectsDir = path.resolve(root, options.objectsDir);
|
|
44
|
+
const sourcesDir = path.resolve(root, options.sourcesDir);
|
|
43
45
|
|
|
44
46
|
this.addContextDependency(modelsDir);
|
|
45
47
|
this.addContextDependency(blocksDir);
|
|
@@ -47,6 +49,7 @@ export default function backmatterLoader(this: BackmatterLoaderContext, source:
|
|
|
47
49
|
this.addContextDependency(mdxDir);
|
|
48
50
|
this.addContextDependency(layoutsDir);
|
|
49
51
|
this.addContextDependency(objectsDir);
|
|
52
|
+
this.addContextDependency(sourcesDir);
|
|
50
53
|
this.cacheable?.(true);
|
|
51
54
|
|
|
52
55
|
const result = generate({
|
|
@@ -58,6 +61,7 @@ export default function backmatterLoader(this: BackmatterLoaderContext, source:
|
|
|
58
61
|
mdxDir: options.mdxDir,
|
|
59
62
|
layoutsDir: options.layoutsDir,
|
|
60
63
|
objectsDir: options.objectsDir,
|
|
64
|
+
sourcesDir: options.sourcesDir,
|
|
61
65
|
sdk: options.sdk,
|
|
62
66
|
});
|
|
63
67
|
if (result.configFile) this.addDependency(result.configFile);
|
package/src/page-render.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// packages/next/src/page-render.ts
|
|
2
2
|
import { createElement, type ComponentType, type ReactNode } from "react";
|
|
3
|
-
import { imageUrlFromUnknown, type BackmatterDoc } from "@backmatter/core";
|
|
3
|
+
import { RESERVED_BLOCK_PROP, imageUrlFromUnknown, type BackmatterDoc } from "@backmatter/core";
|
|
4
4
|
|
|
5
5
|
export type BlockModule = Record<string, unknown>;
|
|
6
6
|
export type BlockLoader = (() => Promise<BlockModule>) & { $$backmatterLoader?: boolean };
|
|
@@ -75,11 +75,15 @@ export async function LazyBlock(props: {
|
|
|
75
75
|
return createElement(Cmp, props.blockProps);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Render a page document's `blocks` array. Unknown types are skipped.
|
|
80
|
+
* `data` is the external source record; every block receives it as `props.data`.
|
|
81
|
+
*/
|
|
79
82
|
export async function renderPage(
|
|
80
83
|
doc: Pick<BackmatterDoc, "frontmatter">,
|
|
81
84
|
components?: BlockComponents,
|
|
82
85
|
_fallbacks?: BlockFallbacks,
|
|
86
|
+
data?: unknown,
|
|
83
87
|
): Promise<ReactNode> {
|
|
84
88
|
const blocks = asBlocks(doc.frontmatter.blocks);
|
|
85
89
|
const children = await Promise.all(
|
|
@@ -104,7 +108,7 @@ export async function renderPage(
|
|
|
104
108
|
return null;
|
|
105
109
|
}
|
|
106
110
|
const key = typeof block.id === "string" ? block.id : undefined;
|
|
107
|
-
return createElement(Cmp, { key, ...props });
|
|
111
|
+
return createElement(Cmp, { key, ...props, [RESERVED_BLOCK_PROP]: data });
|
|
108
112
|
}),
|
|
109
113
|
);
|
|
110
114
|
const rendered = children.filter((child) => child != null);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// packages/next/src/preview-content.ts
|
|
2
|
+
//
|
|
3
|
+
// While a preview render is on the stack, cms.getDoc and cms.listDocs read the
|
|
4
|
+
// checkout (HEAD overlaid with that user's working tree). Published renders
|
|
5
|
+
// never enter this store, so they stay on the cached client.
|
|
6
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
|
+
import type { BackmatterDoc, BackmatterDocSummary } from "@backmatter/core";
|
|
8
|
+
import { fetchPreviewDoc, fetchPreviewDocs } from "@backmatter/core/preview";
|
|
9
|
+
|
|
10
|
+
export type PreviewContentConfig = {
|
|
11
|
+
apiUrl: string;
|
|
12
|
+
workspaceId: string;
|
|
13
|
+
previewToken: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type PreviewContent = {
|
|
17
|
+
getDoc(locale: string, type: string, slug: string): Promise<BackmatterDoc | null>;
|
|
18
|
+
listDocs(filter?: { type?: string; locale?: string }): Promise<BackmatterDocSummary[]>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const current = new AsyncLocalStorage<PreviewContent>();
|
|
22
|
+
|
|
23
|
+
export function previewContentForCheckout(
|
|
24
|
+
config: PreviewContentConfig,
|
|
25
|
+
checkout: { branch?: string; tree?: string },
|
|
26
|
+
): PreviewContent {
|
|
27
|
+
const options = {
|
|
28
|
+
apiUrl: config.apiUrl,
|
|
29
|
+
workspaceId: config.workspaceId,
|
|
30
|
+
previewToken: config.previewToken,
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
getDoc(locale, type, slug) {
|
|
34
|
+
return fetchPreviewDoc(options, { locale, type, slug, ...checkout });
|
|
35
|
+
},
|
|
36
|
+
listDocs(filter) {
|
|
37
|
+
return fetchPreviewDocs(options, { ...checkout, ...filter });
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function currentPreviewContent(): PreviewContent | null {
|
|
43
|
+
return current.getStore() ?? null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function runWithPreviewContent<T>(content: PreviewContent | null, fn: () => T): T {
|
|
47
|
+
if (!content) return fn();
|
|
48
|
+
return current.run(content, fn);
|
|
49
|
+
}
|
package/src/preview-live.tsx
CHANGED
|
@@ -105,6 +105,15 @@ export function PreviewPageReloader({
|
|
|
105
105
|
return null;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
export function PreviewRemoved() {
|
|
109
|
+
return (
|
|
110
|
+
<section>
|
|
111
|
+
<h1>Removed in your working tree</h1>
|
|
112
|
+
<p>This document is deleted until you commit or revert.</p>
|
|
113
|
+
</section>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
108
117
|
export function PreviewLiveSlot({ children }: { children?: ReactNode }) {
|
|
109
118
|
return children ?? null;
|
|
110
119
|
}
|