@backmatter/next 0.0.6

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/src/devbar.tsx ADDED
@@ -0,0 +1,280 @@
1
+ // packages/next/src/devbar.tsx
2
+ "use client";
3
+
4
+ import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
5
+ import {
6
+ clearHostPreviewSession,
7
+ fetchCmsSession,
8
+ fetchHostPreviewSession,
9
+ resolveDevMode,
10
+ type CmsSessionUser,
11
+ } from "@backmatter/core/preview";
12
+
13
+ export type { CmsSessionUser };
14
+
15
+ export type DevModeState =
16
+ | { status: "hidden" }
17
+ | {
18
+ status: "ready";
19
+ user: CmsSessionUser;
20
+ mode: "preview" | "live";
21
+ branch?: string;
22
+ tree?: string;
23
+ };
24
+
25
+ export function devModeState(
26
+ user: CmsSessionUser | null,
27
+ session: { enabled: boolean; branch?: string; tree?: string },
28
+ ): DevModeState {
29
+ if (!user) return { status: "hidden" };
30
+ const resolved = resolveDevMode(session);
31
+ return {
32
+ status: "ready",
33
+ user,
34
+ ...resolved,
35
+ };
36
+ }
37
+
38
+ export function useDevMode(options: { apiUrl: string }): DevModeState {
39
+ const [state, setState] = useState<DevModeState>({ status: "hidden" });
40
+
41
+ useEffect(() => {
42
+ const apiUrl = options.apiUrl.trim();
43
+ if (!apiUrl) return;
44
+ let cancelled = false;
45
+ void Promise.all([fetchCmsSession(apiUrl), fetchHostPreviewSession("")]).then(
46
+ ([user, session]) => {
47
+ if (cancelled) return;
48
+ setState(devModeState(user, session));
49
+ },
50
+ );
51
+ return () => {
52
+ cancelled = true;
53
+ };
54
+ }, [options.apiUrl]);
55
+
56
+ return state;
57
+ }
58
+
59
+ /** Above typical host sticky headers/modals; below max 32-bit int so a host can still overlay. */
60
+ const DEVBAR_Z_INDEX = 2147483000;
61
+
62
+ const barStyle: CSSProperties = {
63
+ position: "fixed",
64
+ bottom: 0,
65
+ left: "50%",
66
+ transform: "translateX(-50%)",
67
+ zIndex: DEVBAR_Z_INDEX,
68
+ isolation: "isolate",
69
+ pointerEvents: "auto",
70
+ display: "flex",
71
+ alignItems: "center",
72
+ gap: 10,
73
+ minHeight: 32,
74
+ maxWidth: "min(360px, calc(100vw - 24px))",
75
+ padding: "0 12px",
76
+ paddingBottom: "env(safe-area-inset-bottom, 0px)",
77
+ background: "#f4f4f5",
78
+ color: "#71717a",
79
+ border: "1px solid #e4e4e7",
80
+ borderBottom: "none",
81
+ borderTopLeftRadius: 8,
82
+ borderTopRightRadius: 8,
83
+ borderBottomLeftRadius: 0,
84
+ borderBottomRightRadius: 0,
85
+ fontFamily:
86
+ 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
87
+ fontSize: 11,
88
+ fontWeight: 500,
89
+ lineHeight: "32px",
90
+ WebkitFontSmoothing: "antialiased",
91
+ fontVariantNumeric: "tabular-nums",
92
+ boxSizing: "border-box",
93
+ };
94
+
95
+ const brandStyle: CSSProperties = {
96
+ color: "#18181b",
97
+ letterSpacing: "-0.02em",
98
+ };
99
+
100
+ const itemStyle: CSSProperties = {
101
+ display: "inline-flex",
102
+ alignItems: "center",
103
+ gap: 6,
104
+ minWidth: 0,
105
+ };
106
+
107
+ const dividerStyle: CSSProperties = {
108
+ width: 1,
109
+ height: 12,
110
+ flexShrink: 0,
111
+ background: "#e4e4e7",
112
+ };
113
+
114
+ const branchTextStyle: CSSProperties = {
115
+ overflow: "hidden",
116
+ textOverflow: "ellipsis",
117
+ whiteSpace: "nowrap",
118
+ maxWidth: 140,
119
+ color: "#18181b",
120
+ };
121
+
122
+ const exitButtonStyle: CSSProperties = {
123
+ appearance: "none",
124
+ border: "none",
125
+ background: "transparent",
126
+ padding: "0 2px",
127
+ margin: 0,
128
+ cursor: "pointer",
129
+ color: "#18181b",
130
+ fontFamily: "inherit",
131
+ fontSize: 11,
132
+ fontWeight: 500,
133
+ lineHeight: "32px",
134
+ minHeight: 32,
135
+ touchAction: "manipulation",
136
+ WebkitFontSmoothing: "antialiased",
137
+ };
138
+
139
+ function Dot({ color }: { color: string }) {
140
+ return (
141
+ <span
142
+ aria-hidden
143
+ style={{
144
+ width: 6,
145
+ height: 6,
146
+ borderRadius: 999,
147
+ background: color,
148
+ flexShrink: 0,
149
+ }}
150
+ />
151
+ );
152
+ }
153
+
154
+ function BranchIcon() {
155
+ return (
156
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
157
+ <circle cx="4.5" cy="4" r="1.75" stroke="currentColor" strokeWidth="1.25" />
158
+ <circle cx="11.5" cy="12" r="1.75" stroke="currentColor" strokeWidth="1.25" />
159
+ <circle cx="4.5" cy="12" r="1.75" stroke="currentColor" strokeWidth="1.25" />
160
+ <path
161
+ d="M4.5 5.75v4.5M4.5 8.5h4.2a3 3 0 0 1 3 3"
162
+ stroke="currentColor"
163
+ strokeWidth="1.25"
164
+ strokeLinecap="round"
165
+ />
166
+ </svg>
167
+ );
168
+ }
169
+
170
+ function Segment({ children }: { children: ReactNode }) {
171
+ return <span style={itemStyle}>{children}</span>;
172
+ }
173
+
174
+ function Divider() {
175
+ return <span aria-hidden style={dividerStyle} />;
176
+ }
177
+
178
+ export function DevBarView({
179
+ state,
180
+ onExit,
181
+ exiting,
182
+ }: {
183
+ state: DevModeState;
184
+ onExit?: () => void;
185
+ exiting?: boolean;
186
+ }) {
187
+ if (state.status !== "ready") return null;
188
+ const live = state.mode === "live";
189
+ const modeLabel = live ? "Live" : "Preview";
190
+ const draft = Boolean(state.tree);
191
+ const parts = ["backmatter", modeLabel];
192
+ if (state.branch) parts.push(state.branch);
193
+ if (draft) parts.push("draft");
194
+ const ariaLabel = parts.join(", ");
195
+
196
+ return (
197
+ <div role="status" aria-label={ariaLabel} style={barStyle}>
198
+ <style>{`
199
+ @media (hover: hover) {
200
+ button.backmatter-devbar-exit:hover:not(:disabled) { color: #71717a; }
201
+ }
202
+ button.backmatter-devbar-exit:focus-visible {
203
+ outline: 2px solid #18181b;
204
+ outline-offset: 2px;
205
+ }
206
+ button.backmatter-devbar-exit:disabled {
207
+ cursor: default;
208
+ opacity: 0.5;
209
+ }
210
+ `}</style>
211
+ <Segment>
212
+ <span style={brandStyle}>backmatter</span>
213
+ </Segment>
214
+ <Divider />
215
+ <Segment>
216
+ <Dot color={live ? "#059669" : "#2563eb"} />
217
+ <span style={{ color: "#18181b" }}>{modeLabel}</span>
218
+ </Segment>
219
+ {state.branch ? (
220
+ <>
221
+ <Divider />
222
+ <Segment>
223
+ <BranchIcon />
224
+ <span style={branchTextStyle}>{state.branch}</span>
225
+ </Segment>
226
+ </>
227
+ ) : null}
228
+ {draft ? (
229
+ <>
230
+ <Divider />
231
+ <Segment>draft</Segment>
232
+ </>
233
+ ) : null}
234
+ {!live ? (
235
+ <>
236
+ <Divider />
237
+ <button
238
+ type="button"
239
+ className="backmatter-devbar-exit"
240
+ aria-label="Exit preview"
241
+ disabled={exiting}
242
+ onClick={() => onExit?.()}
243
+ style={exitButtonStyle}
244
+ >
245
+ Exit
246
+ </button>
247
+ </>
248
+ ) : null}
249
+ </div>
250
+ );
251
+ }
252
+
253
+ export function DevBar({ apiUrl }: { apiUrl: string }) {
254
+ const state = useDevMode({ apiUrl });
255
+ const [exiting, setExiting] = useState(false);
256
+ return (
257
+ <DevBarView
258
+ state={state}
259
+ exiting={exiting}
260
+ onExit={() => {
261
+ if (exiting) return;
262
+ setExiting(true);
263
+ void exitHostPreview().then((ok) => {
264
+ if (!ok) setExiting(false);
265
+ });
266
+ }}
267
+ />
268
+ );
269
+ }
270
+
271
+ export async function exitHostPreview(
272
+ clear: (origin?: string) => Promise<boolean> = clearHostPreviewSession,
273
+ reload: () => void = () => {
274
+ location.reload();
275
+ },
276
+ ): Promise<boolean> {
277
+ const ok = await clear("");
278
+ if (ok) reload();
279
+ return ok;
280
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ // packages/next/src/index.ts
2
+ // Isomorphic re-export of `@backmatter/core`, plus the Next `createBackmatter` wrapper.
3
+ // Do not import this entry from `"use client"` files (blocks, preview):
4
+ // `createBackmatter` loads `@backmatter/core/node/create-backmatter`. Client code uses
5
+ // `@backmatter/core` (defineBlock, field) or `@backmatter/next/preview` /
6
+ // `@backmatter/next/page`.
7
+ export * from "@backmatter/core";
8
+ export type { CreateBackmatterOptions, BackmatterClient } from "@backmatter/core/node/create-backmatter";
9
+ export { createBackmatter } from "./create-backmatter";
package/src/loader.ts ADDED
@@ -0,0 +1,59 @@
1
+ // packages/next/src/loader.ts
2
+ // Bundler loader for `.backmatter/index.ts`, `.backmatter/blocks.ts`, `.backmatter/mdx.ts`,
3
+ // and `.backmatter/layouts.ts`.
4
+ //
5
+ // This is the Tailwind-JIT mechanism: instead of running our own watcher we
6
+ // tell the bundler that these modules depend on the `models/`, `blocks/`,
7
+ // `mdx/`, and `layouts/` directories. webpack and Turbopack already watch the filesystem;
8
+ // when a file is added, removed or renamed there they invalidate the module,
9
+ // re-run this loader, and we regenerate the barrel from a fresh `readdir`.
10
+ import path from "node:path";
11
+ import { generate } from "@backmatter/core/generate";
12
+
13
+ export type BackmatterLoaderOptions = {
14
+ root: string;
15
+ outDir: string;
16
+ modelsDir: string;
17
+ blocksDir: string;
18
+ mdxDir: string;
19
+ layoutsDir: string;
20
+ sdk: string;
21
+ };
22
+
23
+ /** The subset of webpack's LoaderContext this loader uses (Turbopack implements it too). */
24
+ export type BackmatterLoaderContext = {
25
+ resourcePath: string;
26
+ getOptions(): BackmatterLoaderOptions;
27
+ addContextDependency(directory: string): void;
28
+ addDependency(file: string): void;
29
+ cacheable?(flag?: boolean): void;
30
+ };
31
+
32
+ export default function backmatterLoader(this: BackmatterLoaderContext, source: string): string {
33
+ const options = this.getOptions();
34
+ const root = path.resolve(options.root);
35
+ const modelsDir = path.resolve(root, options.modelsDir);
36
+ const blocksDir = path.resolve(root, options.blocksDir);
37
+ const mdxDir = path.resolve(root, options.mdxDir);
38
+ const layoutsDir = path.resolve(root, options.layoutsDir);
39
+
40
+ this.addContextDependency(modelsDir);
41
+ this.addContextDependency(blocksDir);
42
+ this.addContextDependency(mdxDir);
43
+ this.addContextDependency(layoutsDir);
44
+ this.cacheable?.(true);
45
+
46
+ const result = generate({
47
+ root,
48
+ outDir: options.outDir,
49
+ modelsDir: options.modelsDir,
50
+ blocksDir: options.blocksDir,
51
+ mdxDir: options.mdxDir,
52
+ layoutsDir: options.layoutsDir,
53
+ sdk: options.sdk,
54
+ });
55
+ if (result.configFile) this.addDependency(result.configFile);
56
+
57
+ const output = result.outputs.find((o) => path.resolve(o.file) === path.resolve(this.resourcePath));
58
+ return output ? output.content : source;
59
+ }
@@ -0,0 +1,29 @@
1
+ // packages/next/src/mdx-client.tsx
2
+ "use client";
3
+
4
+ import { useEffect, useState, type ReactNode } from "react";
5
+ import { compileMdx, type MdxComponents } from "./mdx-compile";
6
+
7
+ export type { MdxComponents };
8
+
9
+ export function MdxBody({
10
+ source,
11
+ components,
12
+ }: {
13
+ source: string;
14
+ components?: MdxComponents;
15
+ }) {
16
+ const [node, setNode] = useState<ReactNode>(null);
17
+
18
+ useEffect(() => {
19
+ let cancelled = false;
20
+ void compileMdx(source, components).then((tree) => {
21
+ if (!cancelled) setNode(tree);
22
+ });
23
+ return () => {
24
+ cancelled = true;
25
+ };
26
+ }, [source, components]);
27
+
28
+ return node;
29
+ }
@@ -0,0 +1,37 @@
1
+ // packages/next/src/mdx-compile.ts
2
+ import { evaluate } from "@mdx-js/mdx";
3
+ import {
4
+ createElement,
5
+ type ComponentType,
6
+ type ReactNode,
7
+ } from "react";
8
+ import * as runtime from "react/jsx-runtime";
9
+ import remarkGfm from "remark-gfm";
10
+
11
+ export type MdxComponents = Record<string, ComponentType<any>>;
12
+
13
+ export async function compileMdx(
14
+ source: string,
15
+ components?: MdxComponents,
16
+ ): Promise<ReactNode> {
17
+ if (!source.trim()) return null;
18
+
19
+ try {
20
+ const { default: Content } = await evaluate(source, {
21
+ ...runtime,
22
+ remarkPlugins: [remarkGfm],
23
+ baseUrl: import.meta.url,
24
+ });
25
+ return createElement(Content, { components });
26
+ } catch (err) {
27
+ const message = err instanceof Error ? err.message : "MDX compile failed";
28
+ return createElement("pre", { role: "alert" }, message);
29
+ }
30
+ }
31
+
32
+ export async function renderDoc(
33
+ doc: { body: string },
34
+ options?: { components?: MdxComponents },
35
+ ): Promise<ReactNode> {
36
+ return compileMdx(doc.body, options?.components);
37
+ }
package/src/mdx.ts ADDED
@@ -0,0 +1,13 @@
1
+ // packages/next/src/mdx.ts
2
+ export { compileMdx, renderDoc } from "./mdx-compile";
3
+ export type { MdxComponents } from "./mdx-compile";
4
+ export {
5
+ BlockSkeleton,
6
+ LazyBlock,
7
+ renderPage,
8
+ } from "./page-render";
9
+ export type {
10
+ BlockComponents,
11
+ BlockFallbacks,
12
+ BlockLoader,
13
+ } from "./page-render";
@@ -0,0 +1,4 @@
1
+ // packages/next/src/middleware.ts
2
+ // Compose helper for hosts that still wrap the preview gate themselves.
3
+ // New hosts should use `@backmatter/next/proxy` from `proxy.ts`.
4
+ export { backmatterProxy as backmatterMiddleware, proxy as middleware } from "./proxy";
@@ -0,0 +1,111 @@
1
+ // packages/next/src/page-render.ts
2
+ import { Suspense, createElement, type ComponentType, type ReactNode } from "react";
3
+ import { imageUrlFromUnknown, type BackmatterDoc } from "@backmatter/core";
4
+
5
+ export type BlockModule = Record<string, unknown>;
6
+ export type BlockLoader = (() => Promise<BlockModule>) & { $$backmatterLoader?: boolean };
7
+ export type BlockComponents = Record<string, ComponentType<any> | BlockLoader>;
8
+ export type BlockFallbacks = Record<string, ComponentType<any>>;
9
+
10
+ type BlockInstance = {
11
+ id?: unknown;
12
+ type?: unknown;
13
+ props?: unknown;
14
+ };
15
+
16
+ function asBlocks(value: unknown): BlockInstance[] {
17
+ return Array.isArray(value) ? (value as BlockInstance[]) : [];
18
+ }
19
+
20
+ export function BlockSkeleton() {
21
+ return createElement("div", {
22
+ "aria-busy": true,
23
+ "aria-hidden": true,
24
+ style: { minHeight: 1 },
25
+ });
26
+ }
27
+
28
+ export function resolveBlockComponent(mod: BlockModule, type: string): ComponentType<any> | undefined {
29
+ const named = mod[type];
30
+ if (named) return named as ComponentType<any>;
31
+ const def = mod.default;
32
+ if (typeof def === "function") return def as ComponentType<any>;
33
+ if (def && typeof def === "object" && "component" in def && typeof (def as { component?: unknown }).component !== "undefined") {
34
+ return (def as { component?: ComponentType<any> }).component;
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ function isGeneratedBlockLoader(fn: ComponentType<any> | BlockLoader): boolean {
40
+ return (
41
+ fn.name === "" ||
42
+ (fn as ComponentType<any> & { $$backmatterLoader?: boolean }).$$backmatterLoader === true
43
+ );
44
+ }
45
+
46
+ export function asBlockLoader(type: string, value: ComponentType<any> | BlockLoader): BlockLoader {
47
+ if (isGeneratedBlockLoader(value)) return value as BlockLoader;
48
+ return () => Promise.resolve({ [type]: value as ComponentType<any> });
49
+ }
50
+
51
+ const resolvedBlocks = new WeakMap<BlockLoader, Promise<ComponentType<any> | undefined>>();
52
+
53
+ export async function LazyBlock(props: {
54
+ type: string;
55
+ loader: BlockLoader;
56
+ blockProps: Record<string, unknown>;
57
+ }) {
58
+ let pending = resolvedBlocks.get(props.loader);
59
+ if (!pending) {
60
+ pending = props.loader().then((mod) => resolveBlockComponent(mod, props.type));
61
+ resolvedBlocks.set(props.loader, pending);
62
+ }
63
+ const Cmp = await pending;
64
+ if (!Cmp) {
65
+ console.warn(`[backmatter] skipping unknown block type "${props.type}"`);
66
+ return null;
67
+ }
68
+ return createElement(Cmp, props.blockProps);
69
+ }
70
+
71
+ /** Render a page document's `blocks` array. Unknown types are skipped. */
72
+ export function renderPage(
73
+ doc: Pick<BackmatterDoc, "frontmatter">,
74
+ components?: BlockComponents,
75
+ fallbacks?: BlockFallbacks,
76
+ ): ReactNode {
77
+ const blocks = asBlocks(doc.frontmatter.blocks);
78
+ const children: ReactNode[] = [];
79
+ for (const block of blocks) {
80
+ if (typeof block.type !== "string") continue;
81
+ const entry = components?.[block.type];
82
+ if (!entry) {
83
+ console.warn(`[backmatter] skipping unknown block type "${block.type}"`);
84
+ continue;
85
+ }
86
+ const raw =
87
+ block.props && typeof block.props === "object" && !Array.isArray(block.props)
88
+ ? (block.props as Record<string, unknown>)
89
+ : {};
90
+ const props: Record<string, unknown> = {};
91
+ for (const [name, value] of Object.entries(raw)) {
92
+ props[name] = imageUrlFromUnknown(value);
93
+ }
94
+ const key = typeof block.id === "string" ? block.id : undefined;
95
+ children.push(
96
+ createElement(
97
+ Suspense,
98
+ {
99
+ key,
100
+ fallback: createElement(fallbacks?.[block.type] ?? BlockSkeleton),
101
+ },
102
+ createElement(LazyBlock, {
103
+ type: block.type,
104
+ loader: asBlockLoader(block.type, entry),
105
+ blockProps: props,
106
+ }),
107
+ ),
108
+ );
109
+ }
110
+ return children.length === 0 ? null : children;
111
+ }
@@ -0,0 +1,108 @@
1
+ // packages/next/src/preview-live.tsx
2
+ "use client";
3
+
4
+ import { useEffect, useState, type ReactNode } from "react";
5
+ import type { BackmatterDoc } from "@backmatter/core";
6
+ import type { PreviewChangeEvent } from "@backmatter/core/preview";
7
+ import { MdxBody, type MdxComponents } from "./mdx-client";
8
+
9
+ export function PreviewUnavailable({ message }: { message?: string }) {
10
+ return (
11
+ <section>
12
+ <h1>Preview unavailable</h1>
13
+ {message ? <p>{message}</p> : <p>This preview session expired or could not load.</p>}
14
+ </section>
15
+ );
16
+ }
17
+
18
+ function streamUrl(ref: { locale: string; type: string; slug: string }) {
19
+ const params = new URLSearchParams({
20
+ stream: "1",
21
+ locale: ref.locale,
22
+ type: ref.type,
23
+ slug: ref.slug,
24
+ });
25
+ return `/api/backmatter/preview?${params}`;
26
+ }
27
+
28
+ export function listenForPreviewChanges(
29
+ ref: { locale: string; type: string; slug: string },
30
+ onChange: () => void,
31
+ options?: { debounceMs?: number },
32
+ ): () => void {
33
+ const debounceMs = options?.debounceMs ?? 250;
34
+ let timer: ReturnType<typeof setTimeout> | undefined;
35
+ const source = new EventSource(streamUrl(ref));
36
+ const handle = () => {
37
+ clearTimeout(timer);
38
+ timer = setTimeout(onChange, debounceMs);
39
+ };
40
+ source.addEventListener("change", handle);
41
+ return () => {
42
+ clearTimeout(timer);
43
+ source.removeEventListener("change", handle);
44
+ source.close();
45
+ };
46
+ }
47
+
48
+ export function PreviewBody({
49
+ doc,
50
+ components,
51
+ children,
52
+ }: {
53
+ doc: Pick<BackmatterDoc, "locale" | "type" | "slug" | "frontmatter" | "body">;
54
+ components?: MdxComponents;
55
+ /** RSC first paint. Replaced in place when SSE sends a new body. */
56
+ children?: ReactNode;
57
+ }) {
58
+ const [body, setBody] = useState<string | null>(null);
59
+ const [, setFrontmatter] = useState(doc.frontmatter);
60
+
61
+ useEffect(() => {
62
+ const source = new EventSource(streamUrl(doc));
63
+ const onMessage = (ev: MessageEvent) => {
64
+ try {
65
+ const data = JSON.parse(String(ev.data)) as PreviewChangeEvent;
66
+ if (data.frontmatter !== undefined) setFrontmatter(data.frontmatter);
67
+ if (data.body !== undefined) setBody(data.body);
68
+ } catch {
69
+ // ignore malformed
70
+ }
71
+ };
72
+ source.addEventListener("change", onMessage as EventListener);
73
+ return () => {
74
+ source.removeEventListener("change", onMessage as EventListener);
75
+ source.close();
76
+ };
77
+ }, [doc.locale, doc.type, doc.slug]);
78
+
79
+ if (body === null) return children ?? <MdxBody source={doc.body} components={components} />;
80
+ return <MdxBody source={body} components={components} />;
81
+ }
82
+
83
+ export function PreviewPageReloader({
84
+ locale,
85
+ type,
86
+ slug,
87
+ refresh,
88
+ }: {
89
+ locale: string;
90
+ type: string;
91
+ slug: string;
92
+ /** Hosts omit this; the Next wrapper passes `router.refresh` in a transition. */
93
+ refresh?: () => void;
94
+ }) {
95
+ useEffect(
96
+ () =>
97
+ listenForPreviewChanges({ locale, type, slug }, () => {
98
+ if (refresh) refresh();
99
+ else window.location.reload();
100
+ }),
101
+ [locale, type, slug, refresh],
102
+ );
103
+ return null;
104
+ }
105
+
106
+ export function PreviewLiveSlot({ children }: { children?: ReactNode }) {
107
+ return children ?? null;
108
+ }
@@ -0,0 +1,17 @@
1
+ // packages/next/src/preview-refresh.tsx
2
+ "use client";
3
+
4
+ import { createElement, useCallback, startTransition } from "react";
5
+ import { useRouter } from "next/navigation";
6
+ import { PreviewPageReloader as PreviewStreamReloader } from "./preview-live";
7
+
8
+ /** Keeps the current page visible while preview content is refetched. */
9
+ export function PreviewPageReloader(props: { locale: string; type: string; slug: string }) {
10
+ const router = useRouter();
11
+ const refresh = useCallback(() => {
12
+ startTransition(() => {
13
+ router.refresh();
14
+ });
15
+ }, [router]);
16
+ return createElement(PreviewStreamReloader, { ...props, refresh });
17
+ }