@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.
@@ -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
- /** Render a page document's `blocks` array. Unknown types are skipped. */
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
+ }
@@ -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
  }
@@ -1,9 +1,18 @@
1
1
  // packages/next/src/preview-refresh.tsx
2
2
  "use client";
3
3
 
4
- import { createElement, useCallback, startTransition } from "react";
4
+ import { useCallback, useEffect, useState, startTransition, type ReactNode } from "react";
5
5
  import { useRouter } from "next/navigation";
6
- import { PreviewPageReloader as PreviewStreamReloader } from "./preview-live";
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 createElement(PreviewStreamReloader, { ...props, refresh });
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
  }
@@ -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
- `${PREVIEW_COOKIE}=${value}`,
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 [name, ...rest] = part.trim().split("=");
61
- if (name === PREVIEW_COOKIE) return rest.join("=");
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
- return new Response(null, {
135
- status: 302,
136
- headers: {
137
- Location: location,
138
- "Set-Cookie": cookieBase(secure, Math.floor(PREVIEW_COOKIE_TTL_MS / 1000), value),
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
- return new Response(null, {
147
- status: 204,
148
- headers: {
149
- "Set-Cookie": cookieBase(secure, 0, ""),
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(BACKMATTER_CACHE_TAG, { expire: 0 });
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
  };