@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
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
|
}
|
package/src/preview-refresh.tsx
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
// packages/next/src/preview-refresh.tsx
|
|
2
2
|
"use client";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { useCallback, useEffect, useState, startTransition, type ReactNode } from "react";
|
|
5
5
|
import { useRouter } from "next/navigation";
|
|
6
|
-
import {
|
|
6
|
+
import { PREVIEW_CHECKOUT_QUERY } from "@backmatter/core";
|
|
7
|
+
import {
|
|
8
|
+
createPreviewPage,
|
|
9
|
+
createWindowPreviewTransport,
|
|
10
|
+
type PreviewChannelRef,
|
|
11
|
+
type PreviewRenderDoc,
|
|
12
|
+
type PreviewTransport,
|
|
13
|
+
} from "@backmatter/core/preview";
|
|
14
|
+
import { fanInPreviewTransports, sitePageTransport } from "./site-channel";
|
|
15
|
+
import { PreviewPageReloader as PreviewStreamReloader, PreviewRemoved } from "./preview-live";
|
|
7
16
|
|
|
8
17
|
/** Keeps the current page visible while preview content is refetched. */
|
|
9
18
|
export function PreviewPageReloader(props: { locale: string; type: string; slug: string }) {
|
|
@@ -13,5 +22,213 @@ export function PreviewPageReloader(props: { locale: string; type: string; slug:
|
|
|
13
22
|
router.refresh();
|
|
14
23
|
});
|
|
15
24
|
}, [router]);
|
|
16
|
-
return
|
|
25
|
+
return <PreviewStreamReloader {...props} refresh={refresh} />;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type PreviewRenderAction = (
|
|
29
|
+
ref: PreviewChannelRef,
|
|
30
|
+
doc: PreviewRenderDoc,
|
|
31
|
+
checkoutToken?: string,
|
|
32
|
+
) => Promise<{ ok: true; node: ReactNode } | { ok: false; error: string }>;
|
|
33
|
+
|
|
34
|
+
function referrerOrigin(): string {
|
|
35
|
+
try {
|
|
36
|
+
return document.referrer ? new URL(document.referrer).origin : "";
|
|
37
|
+
} catch {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function dashboardOriginFromMeta(): string {
|
|
43
|
+
const marked = document
|
|
44
|
+
.querySelector('meta[name="backmatter:dashboard"]')
|
|
45
|
+
?.getAttribute("content");
|
|
46
|
+
if (marked) return marked.replace(/\/$/, "");
|
|
47
|
+
if (location.hostname === "localhost" || location.hostname === "127.0.0.1") {
|
|
48
|
+
return `${location.protocol}//${location.hostname}:5173`;
|
|
49
|
+
}
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function subscribeDocStream(
|
|
54
|
+
ref: { locale: string; type: string; slug: string },
|
|
55
|
+
onEvent: (event: { raw?: string; body?: string; frontmatter?: Record<string, unknown> }) => void,
|
|
56
|
+
): () => void {
|
|
57
|
+
const params = new URLSearchParams({
|
|
58
|
+
stream: "1",
|
|
59
|
+
locale: ref.locale,
|
|
60
|
+
type: ref.type,
|
|
61
|
+
slug: ref.slug,
|
|
62
|
+
});
|
|
63
|
+
const checkout = new URLSearchParams(globalThis.location?.search ?? "").get(PREVIEW_CHECKOUT_QUERY);
|
|
64
|
+
if (checkout) params.set(PREVIEW_CHECKOUT_QUERY, checkout);
|
|
65
|
+
const source = new EventSource(`/api/backmatter/preview?${params}`);
|
|
66
|
+
const onChange = (ev: MessageEvent) => {
|
|
67
|
+
try {
|
|
68
|
+
const data = JSON.parse(String(ev.data)) as {
|
|
69
|
+
raw?: unknown;
|
|
70
|
+
body?: unknown;
|
|
71
|
+
frontmatter?: unknown;
|
|
72
|
+
};
|
|
73
|
+
onEvent({
|
|
74
|
+
...(typeof data.raw === "string" ? { raw: data.raw } : {}),
|
|
75
|
+
...(typeof data.body === "string" ? { body: data.body } : {}),
|
|
76
|
+
...(data.frontmatter && typeof data.frontmatter === "object" && !Array.isArray(data.frontmatter)
|
|
77
|
+
? { frontmatter: data.frontmatter as Record<string, unknown> }
|
|
78
|
+
: {}),
|
|
79
|
+
});
|
|
80
|
+
} catch {
|
|
81
|
+
// ignore malformed
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
source.addEventListener("change", onChange as EventListener);
|
|
85
|
+
return () => {
|
|
86
|
+
source.removeEventListener("change", onChange as EventListener);
|
|
87
|
+
source.close();
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Refresh when some other document in this checkout changes, or HEAD moves. */
|
|
92
|
+
function subscribeTreeStream(
|
|
93
|
+
ref: { locale: string; type: string; slug: string },
|
|
94
|
+
refresh: () => void,
|
|
95
|
+
): () => void {
|
|
96
|
+
const params = new URLSearchParams({ stream: "tree" });
|
|
97
|
+
const checkout = new URLSearchParams(globalThis.location?.search ?? "").get(PREVIEW_CHECKOUT_QUERY);
|
|
98
|
+
if (checkout) params.set(PREVIEW_CHECKOUT_QUERY, checkout);
|
|
99
|
+
const source = new EventSource(`/api/backmatter/preview?${params}`);
|
|
100
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
101
|
+
const schedule = () => {
|
|
102
|
+
if (timer) return;
|
|
103
|
+
timer = setTimeout(() => {
|
|
104
|
+
timer = null;
|
|
105
|
+
refresh();
|
|
106
|
+
}, 50);
|
|
107
|
+
};
|
|
108
|
+
const onChange = (ev: MessageEvent) => {
|
|
109
|
+
try {
|
|
110
|
+
const data = JSON.parse(String(ev.data)) as { ref?: { locale?: string; type?: string; slug?: string } };
|
|
111
|
+
const changed = data.ref;
|
|
112
|
+
if (
|
|
113
|
+
changed &&
|
|
114
|
+
changed.locale === ref.locale &&
|
|
115
|
+
changed.type === ref.type &&
|
|
116
|
+
changed.slug === ref.slug
|
|
117
|
+
) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// a malformed payload still means something in the tree moved
|
|
122
|
+
}
|
|
123
|
+
schedule();
|
|
124
|
+
};
|
|
125
|
+
source.addEventListener("change", onChange as EventListener);
|
|
126
|
+
source.addEventListener("head", schedule as EventListener);
|
|
127
|
+
return () => {
|
|
128
|
+
if (timer) clearTimeout(timer);
|
|
129
|
+
source.removeEventListener("change", onChange as EventListener);
|
|
130
|
+
source.removeEventListener("head", schedule as EventListener);
|
|
131
|
+
source.close();
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Applies editor `doc` messages and SSE payloads through `renderPreview`
|
|
137
|
+
* and swaps the body. First paint is `children`.
|
|
138
|
+
*/
|
|
139
|
+
export function PreviewClient({
|
|
140
|
+
locale,
|
|
141
|
+
type,
|
|
142
|
+
slug,
|
|
143
|
+
branch,
|
|
144
|
+
tree,
|
|
145
|
+
dashboardOrigins = [],
|
|
146
|
+
renderPreview,
|
|
147
|
+
children,
|
|
148
|
+
}: {
|
|
149
|
+
locale: string;
|
|
150
|
+
type: string;
|
|
151
|
+
slug: string;
|
|
152
|
+
branch?: string;
|
|
153
|
+
tree?: string;
|
|
154
|
+
dashboardOrigins?: string[];
|
|
155
|
+
renderPreview: PreviewRenderAction;
|
|
156
|
+
children?: ReactNode;
|
|
157
|
+
}) {
|
|
158
|
+
const router = useRouter();
|
|
159
|
+
const [node, setNode] = useState<ReactNode>(null);
|
|
160
|
+
const [removed, setRemoved] = useState(false);
|
|
161
|
+
const [error, setError] = useState<string | null>(null);
|
|
162
|
+
const originsKey = dashboardOrigins.join("\0");
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
const ref: PreviewChannelRef = {
|
|
166
|
+
locale,
|
|
167
|
+
type,
|
|
168
|
+
slug,
|
|
169
|
+
...(branch ? { branch } : {}),
|
|
170
|
+
...(tree ? { tree } : {}),
|
|
171
|
+
};
|
|
172
|
+
const parentOrigin = referrerOrigin();
|
|
173
|
+
const dashboardOrigin = dashboardOriginFromMeta();
|
|
174
|
+
const allowed = [...originsKey.split("\0"), parentOrigin, dashboardOrigin].filter(
|
|
175
|
+
(origin) => origin.length > 0,
|
|
176
|
+
);
|
|
177
|
+
const framed = window.parent !== window;
|
|
178
|
+
const targetOrigin = framed ? parentOrigin || allowed[0] || "" : dashboardOrigin;
|
|
179
|
+
const windowTransport: PreviewTransport | null = targetOrigin
|
|
180
|
+
? createWindowPreviewTransport({
|
|
181
|
+
target: framed ? window.parent : { postMessage() {} },
|
|
182
|
+
targetOrigin,
|
|
183
|
+
allowedOrigins: allowed,
|
|
184
|
+
})
|
|
185
|
+
: null;
|
|
186
|
+
const transport = fanInPreviewTransports(
|
|
187
|
+
windowTransport ? [windowTransport, sitePageTransport()] : [sitePageTransport()],
|
|
188
|
+
);
|
|
189
|
+
const checkoutToken =
|
|
190
|
+
new URLSearchParams(window.location.search).get(PREVIEW_CHECKOUT_QUERY) ?? undefined;
|
|
191
|
+
const refreshOthers = () => {
|
|
192
|
+
startTransition(() => router.refresh());
|
|
193
|
+
};
|
|
194
|
+
const unsubscribeTree = subscribeTreeStream(ref, refreshOthers);
|
|
195
|
+
const session = createPreviewPage({
|
|
196
|
+
ref,
|
|
197
|
+
transport,
|
|
198
|
+
subscribeSse: (onEvent) => subscribeDocStream(ref, onEvent),
|
|
199
|
+
renderer: async (_ref, doc) => {
|
|
200
|
+
const result = await renderPreview(ref, doc, checkoutToken);
|
|
201
|
+
if (!result.ok) return result;
|
|
202
|
+
return { ok: true, value: result.node };
|
|
203
|
+
},
|
|
204
|
+
handlers: {
|
|
205
|
+
onApplied: (_doc, value) => {
|
|
206
|
+
setError(null);
|
|
207
|
+
setRemoved(false);
|
|
208
|
+
setNode(value as ReactNode);
|
|
209
|
+
},
|
|
210
|
+
onError: (message) => setError(message),
|
|
211
|
+
onRemoved: () => {
|
|
212
|
+
setRemoved(true);
|
|
213
|
+
setError(null);
|
|
214
|
+
},
|
|
215
|
+
onNavigate: (path) => {
|
|
216
|
+
startTransition(() => router.push(path));
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
return () => {
|
|
221
|
+
unsubscribeTree();
|
|
222
|
+
session.close();
|
|
223
|
+
windowTransport?.close();
|
|
224
|
+
};
|
|
225
|
+
}, [locale, type, slug, branch, tree, renderPreview, router, originsKey]);
|
|
226
|
+
|
|
227
|
+
if (removed) return <PreviewRemoved />;
|
|
228
|
+
return (
|
|
229
|
+
<div data-backmatter-preview="">
|
|
230
|
+
{error ? <p role="alert">{error}</p> : null}
|
|
231
|
+
{node ?? children}
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
17
234
|
}
|
package/src/preview-route.ts
CHANGED
|
@@ -3,13 +3,15 @@ import {
|
|
|
3
3
|
PREVIEW_CHECKOUT_QUERY,
|
|
4
4
|
PREVIEW_COOKIE,
|
|
5
5
|
PREVIEW_COOKIE_TTL_MS,
|
|
6
|
+
PREVIEW_SESSION_COOKIE,
|
|
7
|
+
PREVIEW_SESSION_TTL_MS,
|
|
6
8
|
previewLocationWithCheckout,
|
|
7
9
|
readPreviewCookie,
|
|
8
10
|
readPreviewTicket,
|
|
9
11
|
writePreviewCookie,
|
|
10
12
|
type PreviewCheckout,
|
|
11
13
|
} from "@backmatter/core";
|
|
12
|
-
import { previewStreamUrl } from "@backmatter/core/preview";
|
|
14
|
+
import { previewStreamUrl, previewTreeStreamUrl } from "@backmatter/core/preview";
|
|
13
15
|
import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
|
|
14
16
|
|
|
15
17
|
export type PreviewRouteDeps = {
|
|
@@ -19,12 +21,12 @@ export type PreviewRouteDeps = {
|
|
|
19
21
|
now?: () => number;
|
|
20
22
|
};
|
|
21
23
|
|
|
22
|
-
function cookieBase(secure: boolean, maxAge: number, value: string) {
|
|
24
|
+
function cookieBase(name: string, secure: boolean, maxAge: number, value: string) {
|
|
23
25
|
// HTTPS is cross-site from the CMS iframe (dash → customer origin). Lax is
|
|
24
26
|
// not sent there, so draftMode can be on while the checkout cookie is missing
|
|
25
27
|
// (`invalid_session`). Partitioned (CHIPS) keeps the cookie in that embed.
|
|
26
28
|
const parts = [
|
|
27
|
-
`${
|
|
29
|
+
`${name}=${value}`,
|
|
28
30
|
"Path=/",
|
|
29
31
|
"HttpOnly",
|
|
30
32
|
`Max-Age=${maxAge}`,
|
|
@@ -53,19 +55,19 @@ async function nextDraftEnabled(): Promise<boolean> {
|
|
|
53
55
|
return (await mod.draftMode()).isEnabled;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
function cookieFrom(req: Request): string | undefined {
|
|
58
|
+
function cookieFrom(req: Request, name: string): string | undefined {
|
|
57
59
|
const header = req.headers.get("cookie");
|
|
58
60
|
if (!header) return undefined;
|
|
59
61
|
for (const part of header.split(";")) {
|
|
60
|
-
const [
|
|
61
|
-
if (
|
|
62
|
+
const [key, ...rest] = part.trim().split("=");
|
|
63
|
+
if (key === name) return rest.join("=");
|
|
62
64
|
}
|
|
63
65
|
return undefined;
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
async function checkoutFrom(req: Request, secret: string, url: URL): Promise<PreviewCheckout | null> {
|
|
67
69
|
return (
|
|
68
|
-
(await readPreviewCookie(secret, cookieFrom(req))) ??
|
|
70
|
+
(await readPreviewCookie(secret, cookieFrom(req, PREVIEW_COOKIE))) ??
|
|
69
71
|
(await readPreviewCookie(secret, url.searchParams.get(PREVIEW_CHECKOUT_QUERY)?.trim()))
|
|
70
72
|
);
|
|
71
73
|
}
|
|
@@ -117,6 +119,26 @@ export function createPreviewRoute(
|
|
|
117
119
|
});
|
|
118
120
|
}
|
|
119
121
|
|
|
122
|
+
if (url.searchParams.get("stream") === "tree") {
|
|
123
|
+
if (!secret || !cms.config.apiUrl || !cms.config.workspaceId) {
|
|
124
|
+
return new Response("preview not configured", { status: 503 });
|
|
125
|
+
}
|
|
126
|
+
const checkout = await checkoutFrom(req, secret, url);
|
|
127
|
+
if (!checkout) return new Response("preview session missing", { status: 401 });
|
|
128
|
+
const upstream = previewTreeStreamUrl(
|
|
129
|
+
{ apiUrl: cms.config.apiUrl, workspaceId: cms.config.workspaceId, previewToken: secret },
|
|
130
|
+
checkout,
|
|
131
|
+
);
|
|
132
|
+
const res = await fetch(upstream);
|
|
133
|
+
return new Response(res.body, {
|
|
134
|
+
status: res.status,
|
|
135
|
+
headers: {
|
|
136
|
+
"Content-Type": "text/event-stream",
|
|
137
|
+
"Cache-Control": "no-store",
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
120
142
|
const ticket = url.searchParams.get("ticket")?.trim();
|
|
121
143
|
if (!ticket) return Response.json({ error: "missing_ticket" }, { status: 400 });
|
|
122
144
|
if (!secret) return Response.json({ error: "preview_not_configured" }, { status: 503 });
|
|
@@ -131,24 +153,32 @@ export function createPreviewRoute(
|
|
|
131
153
|
});
|
|
132
154
|
await (deps.enableDraft ?? (() => nextDraft(true)))();
|
|
133
155
|
const location = previewLocationWithCheckout(url.origin, payload.redirect, value);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
156
|
+
const headers = new Headers({ Location: location });
|
|
157
|
+
headers.append(
|
|
158
|
+
"Set-Cookie",
|
|
159
|
+
cookieBase(PREVIEW_COOKIE, secure, Math.floor(PREVIEW_COOKIE_TTL_MS / 1000), value),
|
|
160
|
+
);
|
|
161
|
+
if (payload.session) {
|
|
162
|
+
headers.append(
|
|
163
|
+
"Set-Cookie",
|
|
164
|
+
cookieBase(
|
|
165
|
+
PREVIEW_SESSION_COOKIE,
|
|
166
|
+
secure,
|
|
167
|
+
Math.floor(PREVIEW_SESSION_TTL_MS / 1000),
|
|
168
|
+
payload.session,
|
|
169
|
+
),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return new Response(null, { status: 302, headers });
|
|
141
173
|
};
|
|
142
174
|
|
|
143
175
|
const POST = async (req: Request): Promise<Response> => {
|
|
144
176
|
await (deps.disableDraft ?? (() => nextDraft(false)))();
|
|
145
177
|
const secure = new URL(req.url).protocol === "https:";
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
},
|
|
151
|
-
});
|
|
178
|
+
const headers = new Headers();
|
|
179
|
+
headers.append("Set-Cookie", cookieBase(PREVIEW_COOKIE, secure, 0, ""));
|
|
180
|
+
headers.append("Set-Cookie", cookieBase(PREVIEW_SESSION_COOKIE, secure, 0, ""));
|
|
181
|
+
return new Response(null, { status: 204, headers });
|
|
152
182
|
};
|
|
153
183
|
|
|
154
184
|
return { GET, POST };
|
|
@@ -160,5 +190,5 @@ export async function loadPreviewCheckout(
|
|
|
160
190
|
): Promise<PreviewCheckout | null> {
|
|
161
191
|
if (!secret) return null;
|
|
162
192
|
const fake = new Request("http://local", { headers: cookieHeader ? { cookie: cookieHeader } : {} });
|
|
163
|
-
return readPreviewCookie(secret, cookieFrom(fake));
|
|
193
|
+
return readPreviewCookie(secret, cookieFrom(fake, PREVIEW_COOKIE));
|
|
164
194
|
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// packages/next/src/render-preview.ts
|
|
2
|
+
// Server action factory. Renders the editor's document with the host's
|
|
3
|
+
// components. Refuses unless draft mode is on and the checkout tree matches.
|
|
4
|
+
import {
|
|
5
|
+
PREVIEW_COOKIE,
|
|
6
|
+
PREVIEW_SESSION_COOKIE,
|
|
7
|
+
readPreviewCookie,
|
|
8
|
+
readPreviewSession,
|
|
9
|
+
type ModelDef,
|
|
10
|
+
type PreviewSessionClaims,
|
|
11
|
+
} from "@backmatter/core";
|
|
12
|
+
import type { ReactNode } from "react";
|
|
13
|
+
import { renderLayoutDoc, type LayoutDocOptions } from "./layout-doc";
|
|
14
|
+
import { previewContentForCheckout, runWithPreviewContent } from "./preview-content";
|
|
15
|
+
import { requireSource, resolveSourceData } from "./source-data";
|
|
16
|
+
|
|
17
|
+
export const RENDER_PREVIEW_PER_SECOND = 20;
|
|
18
|
+
|
|
19
|
+
export type RenderPreviewRef = {
|
|
20
|
+
locale: string;
|
|
21
|
+
type: string;
|
|
22
|
+
slug: string;
|
|
23
|
+
branch?: string;
|
|
24
|
+
tree?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type RenderPreviewDoc = {
|
|
28
|
+
frontmatter: Record<string, unknown>;
|
|
29
|
+
body: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type RenderPreviewResult =
|
|
33
|
+
| { ok: true; node: ReactNode }
|
|
34
|
+
| { ok: false; error: string };
|
|
35
|
+
|
|
36
|
+
export type RenderPreviewDeps = {
|
|
37
|
+
draftEnabled?: () => Promise<boolean> | boolean;
|
|
38
|
+
/** Checkout cookie value. The action argument is used when this is missing. */
|
|
39
|
+
readCheckoutToken?: () => Promise<string | undefined> | string | undefined;
|
|
40
|
+
/** Preview session cookie. Required. */
|
|
41
|
+
readSessionToken?: () => Promise<string | undefined> | string | undefined;
|
|
42
|
+
/** Epoch check. Default asks the CMS when apiUrl is set, and trusts the signature otherwise. */
|
|
43
|
+
sessionFresh?: (token: string, claims: PreviewSessionClaims) => Promise<boolean>;
|
|
44
|
+
now?: () => number;
|
|
45
|
+
limitPerSecond?: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type CmsLike = {
|
|
49
|
+
config: { previewToken?: string; apiUrl?: string; workspaceId?: string };
|
|
50
|
+
models: readonly ModelDef[];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const freshUntil = new Map<string, number>();
|
|
54
|
+
|
|
55
|
+
async function defaultSessionFresh(apiUrl: string, token: string): Promise<boolean> {
|
|
56
|
+
const until = freshUntil.get(token);
|
|
57
|
+
if (until && until > Date.now()) return true;
|
|
58
|
+
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/preview/session`, {
|
|
59
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
60
|
+
cache: "no-store",
|
|
61
|
+
});
|
|
62
|
+
if (!res.ok) return false;
|
|
63
|
+
freshUntil.set(token, Date.now() + 30_000);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const hits = new Map<string, number[]>();
|
|
68
|
+
|
|
69
|
+
export function resetRenderPreviewLimits(): void {
|
|
70
|
+
hits.clear();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function allowRender(key: string, now: number, limit: number): boolean {
|
|
74
|
+
const recent = (hits.get(key) ?? []).filter((at) => now - at < 1000);
|
|
75
|
+
if (recent.length >= limit) {
|
|
76
|
+
hits.set(key, recent);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
recent.push(now);
|
|
80
|
+
hits.set(key, recent);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function defaultDraftEnabled(): Promise<boolean> {
|
|
85
|
+
const mod = (await import("next/headers" as string)) as {
|
|
86
|
+
draftMode: () => Promise<{ isEnabled: boolean }>;
|
|
87
|
+
};
|
|
88
|
+
return (await mod.draftMode()).isEnabled;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function cookieValue(name: string): Promise<string | undefined> {
|
|
92
|
+
const mod = (await import("next/headers" as string)) as {
|
|
93
|
+
cookies: () => Promise<{ get: (cookieName: string) => { value: string } | undefined }>;
|
|
94
|
+
};
|
|
95
|
+
return (await mod.cookies()).get(name)?.value;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function defaultCheckoutToken(): Promise<string | undefined> {
|
|
99
|
+
return cookieValue(PREVIEW_COOKIE);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function defaultSessionToken(): Promise<string | undefined> {
|
|
103
|
+
return cookieValue(PREVIEW_SESSION_COOKIE);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function createRenderPreview(
|
|
107
|
+
cms: CmsLike,
|
|
108
|
+
options: Omit<LayoutDocOptions, "models"> = {},
|
|
109
|
+
deps: RenderPreviewDeps = {},
|
|
110
|
+
) {
|
|
111
|
+
const draftEnabled = deps.draftEnabled ?? defaultDraftEnabled;
|
|
112
|
+
const readCheckoutToken = deps.readCheckoutToken ?? defaultCheckoutToken;
|
|
113
|
+
const readSessionToken = deps.readSessionToken ?? defaultSessionToken;
|
|
114
|
+
const now = deps.now ?? Date.now;
|
|
115
|
+
const limit = deps.limitPerSecond ?? RENDER_PREVIEW_PER_SECOND;
|
|
116
|
+
const secret = cms.config.previewToken ?? "";
|
|
117
|
+
|
|
118
|
+
return async function renderPreview(
|
|
119
|
+
ref: RenderPreviewRef,
|
|
120
|
+
doc: RenderPreviewDoc,
|
|
121
|
+
checkoutToken?: string,
|
|
122
|
+
): Promise<RenderPreviewResult> {
|
|
123
|
+
if (!secret) return { ok: false, error: "unauthorized" };
|
|
124
|
+
if (!(await draftEnabled())) return { ok: false, error: "unauthorized" };
|
|
125
|
+
const token = (await readCheckoutToken()) || checkoutToken;
|
|
126
|
+
const checkout = await readPreviewCookie(secret, token);
|
|
127
|
+
if (!checkout?.tree || !ref.tree || checkout.tree !== ref.tree) {
|
|
128
|
+
return { ok: false, error: "unauthorized" };
|
|
129
|
+
}
|
|
130
|
+
if (checkout.branch && ref.branch && checkout.branch !== ref.branch) {
|
|
131
|
+
return { ok: false, error: "unauthorized" };
|
|
132
|
+
}
|
|
133
|
+
const sessionToken = await readSessionToken();
|
|
134
|
+
const session = await readPreviewSession(secret, sessionToken);
|
|
135
|
+
if (!session || session.tree !== ref.tree) return { ok: false, error: "unauthorized" };
|
|
136
|
+
if (cms.config.workspaceId && session.workspaceId !== cms.config.workspaceId) {
|
|
137
|
+
return { ok: false, error: "unauthorized" };
|
|
138
|
+
}
|
|
139
|
+
if (session.branch && ref.branch && session.branch !== ref.branch) {
|
|
140
|
+
return { ok: false, error: "unauthorized" };
|
|
141
|
+
}
|
|
142
|
+
const fresh =
|
|
143
|
+
deps.sessionFresh ??
|
|
144
|
+
(cms.config.apiUrl ? (value: string) => defaultSessionFresh(cms.config.apiUrl!, value) : null);
|
|
145
|
+
if (fresh && sessionToken && !(await fresh(sessionToken, session))) {
|
|
146
|
+
return { ok: false, error: "unauthorized" };
|
|
147
|
+
}
|
|
148
|
+
const key = `${checkout.branch ?? ""}:${checkout.tree}`;
|
|
149
|
+
if (!allowRender(key, now(), limit)) return { ok: false, error: "rate_limited" };
|
|
150
|
+
try {
|
|
151
|
+
const { apiUrl, workspaceId } = cms.config;
|
|
152
|
+
const content =
|
|
153
|
+
apiUrl && workspaceId
|
|
154
|
+
? previewContentForCheckout({ apiUrl, workspaceId, previewToken: secret }, checkout)
|
|
155
|
+
: null;
|
|
156
|
+
// The editor's draft is the document; the outside record still comes from the source,
|
|
157
|
+
// so prices and images stay live while blocks change.
|
|
158
|
+
const model = cms.models.find((m) => m.type === ref.type);
|
|
159
|
+
let data: unknown;
|
|
160
|
+
if (model) {
|
|
161
|
+
const resolved = await resolveSourceData(requireSource(options.sources, model), model, ref);
|
|
162
|
+
if (!resolved.ok) return { ok: false, error: "record_not_found" };
|
|
163
|
+
data = resolved.data;
|
|
164
|
+
}
|
|
165
|
+
const node = await runWithPreviewContent(content, () =>
|
|
166
|
+
renderLayoutDoc(
|
|
167
|
+
{
|
|
168
|
+
locale: ref.locale,
|
|
169
|
+
type: ref.type,
|
|
170
|
+
slug: ref.slug,
|
|
171
|
+
source: "tree",
|
|
172
|
+
frontmatter: doc.frontmatter,
|
|
173
|
+
body: doc.body,
|
|
174
|
+
raw: doc.body,
|
|
175
|
+
},
|
|
176
|
+
{ ...options, models: cms.models },
|
|
177
|
+
data,
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
return { ok: true, node };
|
|
181
|
+
} catch (err) {
|
|
182
|
+
const message = err instanceof Error ? err.message : "Preview render failed";
|
|
183
|
+
return { ok: false, error: message };
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
}
|
package/src/revalidate.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
import { timingSafeEqual } from "node:crypto";
|
|
5
5
|
import {
|
|
6
6
|
CONTENT_COMMITTED_EVENT,
|
|
7
|
-
BACKMATTER_CACHE_TAG,
|
|
8
7
|
pathsForCommittedDocs,
|
|
8
|
+
revalidateTagsForCommit,
|
|
9
9
|
type ContentCommittedEvent,
|
|
10
10
|
} from "@backmatter/core";
|
|
11
11
|
import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
|
|
@@ -57,7 +57,7 @@ export function createRevalidateRoute(
|
|
|
57
57
|
const { revalidatePath, revalidateTag } = cache ?? (await loadNextCache());
|
|
58
58
|
const paths = pathsForCommittedDocs(cms.models, body.docs ?? []);
|
|
59
59
|
for (const path of paths) revalidatePath(path);
|
|
60
|
-
revalidateTag(
|
|
60
|
+
for (const tag of revalidateTagsForCommit(body)) revalidateTag(tag, { expire: 0 });
|
|
61
61
|
if (body.full) revalidatePath("/", "layout");
|
|
62
62
|
return Response.json({ ok: true, paths });
|
|
63
63
|
};
|