@backmatter/next 0.0.8 → 0.0.9
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/package.json +2 -2
- package/src/page-render.ts +44 -43
- package/src/revalidate.ts +2 -2
- package/src/routes.ts +116 -69
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@backmatter/next",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"description": "Experimental Next.js adapter for Backmatter. APIs will break. Not ready for production.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"test": "bun test"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@backmatter/core": "^0.0.
|
|
41
|
+
"@backmatter/core": "^0.0.9",
|
|
42
42
|
"@mdx-js/mdx": "^3.1.1",
|
|
43
43
|
"remark-gfm": "^4.0.1"
|
|
44
44
|
},
|
package/src/page-render.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// packages/next/src/page-render.ts
|
|
2
|
-
import {
|
|
2
|
+
import { createElement, type ComponentType, type ReactNode } from "react";
|
|
3
3
|
import { imageUrlFromUnknown, type BackmatterDoc } from "@backmatter/core";
|
|
4
4
|
|
|
5
5
|
export type BlockModule = Record<string, unknown>;
|
|
@@ -50,17 +50,24 @@ export function asBlockLoader(type: string, value: ComponentType<any> | BlockLoa
|
|
|
50
50
|
|
|
51
51
|
const resolvedBlocks = new WeakMap<BlockLoader, Promise<ComponentType<any> | undefined>>();
|
|
52
52
|
|
|
53
|
+
async function loadBlockComponent(
|
|
54
|
+
type: string,
|
|
55
|
+
loader: BlockLoader,
|
|
56
|
+
): Promise<ComponentType<any> | undefined> {
|
|
57
|
+
let pending = resolvedBlocks.get(loader);
|
|
58
|
+
if (!pending) {
|
|
59
|
+
pending = loader().then((mod) => resolveBlockComponent(mod, type));
|
|
60
|
+
resolvedBlocks.set(loader, pending);
|
|
61
|
+
}
|
|
62
|
+
return pending;
|
|
63
|
+
}
|
|
64
|
+
|
|
53
65
|
export async function LazyBlock(props: {
|
|
54
66
|
type: string;
|
|
55
67
|
loader: BlockLoader;
|
|
56
68
|
blockProps: Record<string, unknown>;
|
|
57
69
|
}) {
|
|
58
|
-
|
|
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;
|
|
70
|
+
const Cmp = await loadBlockComponent(props.type, props.loader);
|
|
64
71
|
if (!Cmp) {
|
|
65
72
|
console.warn(`[backmatter] skipping unknown block type "${props.type}"`);
|
|
66
73
|
return null;
|
|
@@ -69,43 +76,37 @@ export async function LazyBlock(props: {
|
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
/** Render a page document's `blocks` array. Unknown types are skipped. */
|
|
72
|
-
export function renderPage(
|
|
79
|
+
export async function renderPage(
|
|
73
80
|
doc: Pick<BackmatterDoc, "frontmatter">,
|
|
74
81
|
components?: BlockComponents,
|
|
75
|
-
|
|
76
|
-
): ReactNode {
|
|
82
|
+
_fallbacks?: BlockFallbacks,
|
|
83
|
+
): Promise<ReactNode> {
|
|
77
84
|
const blocks = asBlocks(doc.frontmatter.blocks);
|
|
78
|
-
const children
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
blockProps: props,
|
|
106
|
-
}),
|
|
107
|
-
),
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
return children.length === 0 ? null : children;
|
|
85
|
+
const children = await Promise.all(
|
|
86
|
+
blocks.map(async (block) => {
|
|
87
|
+
if (typeof block.type !== "string") return null;
|
|
88
|
+
const entry = components?.[block.type];
|
|
89
|
+
if (!entry) {
|
|
90
|
+
console.warn(`[backmatter] skipping unknown block type "${block.type}"`);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const raw =
|
|
94
|
+
block.props && typeof block.props === "object" && !Array.isArray(block.props)
|
|
95
|
+
? (block.props as Record<string, unknown>)
|
|
96
|
+
: {};
|
|
97
|
+
const props: Record<string, unknown> = {};
|
|
98
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
99
|
+
props[name] = imageUrlFromUnknown(value);
|
|
100
|
+
}
|
|
101
|
+
const Cmp = await loadBlockComponent(block.type, asBlockLoader(block.type, entry));
|
|
102
|
+
if (!Cmp) {
|
|
103
|
+
console.warn(`[backmatter] skipping unknown block type "${block.type}"`);
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
const key = typeof block.id === "string" ? block.id : undefined;
|
|
107
|
+
return createElement(Cmp, { key, ...props });
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
const rendered = children.filter((child) => child != null);
|
|
111
|
+
return rendered.length === 0 ? null : rendered;
|
|
111
112
|
}
|
package/src/revalidate.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
|
|
|
12
12
|
|
|
13
13
|
export type RevalidateCache = {
|
|
14
14
|
revalidatePath: (path: string, type?: "layout" | "page") => void;
|
|
15
|
-
revalidateTag: (tag: string) => void;
|
|
15
|
+
revalidateTag: (tag: string, profile?: string | { expire?: number }) => void;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
18
|
function extractBearer(header: string | null): string | null {
|
|
@@ -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);
|
|
60
|
+
revalidateTag(BACKMATTER_CACHE_TAG, { expire: 0 });
|
|
61
61
|
if (body.full) revalidatePath("/", "layout");
|
|
62
62
|
return Response.json({ ok: true, paths });
|
|
63
63
|
};
|
package/src/routes.ts
CHANGED
|
@@ -3,15 +3,13 @@
|
|
|
3
3
|
// app/[[...backmatter]]/page.tsx. It maps the URL to a document via each model's
|
|
4
4
|
// `route` template (including `/` via indexSlug), loads it and renders MDX
|
|
5
5
|
// or JSON pages. An optional catch-all is required so `/` is a CMS route.
|
|
6
|
-
import { Suspense, createElement, type ReactNode } from "react";
|
|
6
|
+
import { Fragment, Suspense, createElement, type ReactNode } from "react";
|
|
7
7
|
import { matchRoute, modelFormat, PREVIEW_CHECKOUT_QUERY, PREVIEW_COOKIE, readPreviewCookie, routeSegments, type BackmatterDoc, type ModelDef } from "@backmatter/core";
|
|
8
8
|
import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
|
|
9
9
|
import { fetchPreviewDoc } from "@backmatter/core/preview";
|
|
10
10
|
import { renderDoc, type MdxComponents } from "./mdx-compile";
|
|
11
11
|
import { getCachedDoc, registerCachedDocGet } from "./cached-doc";
|
|
12
|
-
import {
|
|
13
|
-
import { PreviewBody } from "./preview-live";
|
|
14
|
-
import { PreviewPageReloader } from "./preview-refresh";
|
|
12
|
+
import { renderPage, type BlockComponents, type BlockFallbacks } from "./page-render";
|
|
15
13
|
import { previewUnavailable } from "./preview-ui";
|
|
16
14
|
|
|
17
15
|
export const CATCH_ALL_PARAM = "backmatter";
|
|
@@ -25,7 +23,7 @@ export type CatchAllOptions = {
|
|
|
25
23
|
components?: MdxComponents;
|
|
26
24
|
/** Composer map, usually the generated `blocks`. */
|
|
27
25
|
blocks?: BlockComponents;
|
|
28
|
-
/**
|
|
26
|
+
/** Kept for host compatibility. Published pages resolve blocks before paint. */
|
|
29
27
|
fallbacks?: BlockFallbacks;
|
|
30
28
|
/** Per-type wrappers, usually the generated `layouts`. */
|
|
31
29
|
layouts?: Record<string, DocLayout>;
|
|
@@ -89,6 +87,8 @@ type PreviewState =
|
|
|
89
87
|
| { enabled: false }
|
|
90
88
|
| { enabled: true; checkout: { branch?: string; tree?: string }; error?: string };
|
|
91
89
|
|
|
90
|
+
type RouteHit = { locale: string; type: string; slug: string };
|
|
91
|
+
|
|
92
92
|
async function checkoutQueryToken(
|
|
93
93
|
searchParams: Promise<CatchAllSearchParams> | CatchAllSearchParams | undefined,
|
|
94
94
|
): Promise<string | undefined> {
|
|
@@ -157,81 +157,128 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
|
|
|
157
157
|
return params;
|
|
158
158
|
};
|
|
159
159
|
|
|
160
|
-
const
|
|
160
|
+
const failFor = options.notFound ?? nextNotFound;
|
|
161
161
|
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
const hit = resolve(resolved[CATCH_ALL_PARAM]);
|
|
165
|
-
const fail = options.notFound ?? nextNotFound;
|
|
166
|
-
if (!hit) return fail();
|
|
167
|
-
const preview = await resolvePreviewState(cms, options.preview, searchParams);
|
|
168
|
-
let doc: BackmatterDoc | null;
|
|
169
|
-
if (preview.enabled) {
|
|
170
|
-
if (preview.error) return previewUnavailable(preview.error);
|
|
171
|
-
try {
|
|
172
|
-
const getDoc =
|
|
173
|
-
options.preview?.getDoc ??
|
|
174
|
-
((ref) =>
|
|
175
|
-
fetchPreviewDoc(
|
|
176
|
-
{
|
|
177
|
-
apiUrl: cms.config.apiUrl ?? "",
|
|
178
|
-
workspaceId: cms.config.workspaceId ?? "",
|
|
179
|
-
previewToken: cms.config.previewToken ?? "",
|
|
180
|
-
},
|
|
181
|
-
ref,
|
|
182
|
-
));
|
|
183
|
-
doc = await getDoc({
|
|
184
|
-
locale: hit.locale,
|
|
185
|
-
type: hit.type,
|
|
186
|
-
slug: hit.slug,
|
|
187
|
-
...preview.checkout,
|
|
188
|
-
});
|
|
189
|
-
} catch (err) {
|
|
190
|
-
return previewUnavailable(err instanceof Error ? err.message : "Failed to load");
|
|
191
|
-
}
|
|
192
|
-
} else {
|
|
193
|
-
doc = await getCachedDoc(hit.locale, hit.type, hit.slug);
|
|
194
|
-
}
|
|
195
|
-
if (!doc) return fail();
|
|
196
|
-
const model = byType.get(hit.type);
|
|
162
|
+
async function layoutDoc(doc: BackmatterDoc, previewEnabled: boolean): Promise<ReactNode> {
|
|
163
|
+
const model = byType.get(doc.type);
|
|
197
164
|
const isPage = Boolean(model && modelFormat(model) === "page");
|
|
198
165
|
const rendered = isPage
|
|
199
|
-
? renderPage(doc, options.blocks, options.fallbacks)
|
|
166
|
+
? await renderPage(doc, options.blocks, options.fallbacks)
|
|
200
167
|
: await renderDoc(doc, { components: options.components });
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
)
|
|
222
|
-
: body;
|
|
223
|
-
const layout = options.layouts?.[hit.type];
|
|
224
|
-
const laidOut = layout ? layout(doc, withLive) : withLive;
|
|
168
|
+
let body = rendered;
|
|
169
|
+
if (!isPage && previewEnabled) {
|
|
170
|
+
const { PreviewBody } = await import("./preview-live");
|
|
171
|
+
body = createElement(PreviewBody, { doc, components: options.components }, rendered);
|
|
172
|
+
}
|
|
173
|
+
if (previewEnabled && isPage) {
|
|
174
|
+
const { PreviewPageReloader } = await import("./preview-refresh");
|
|
175
|
+
body = createElement(
|
|
176
|
+
"div",
|
|
177
|
+
null,
|
|
178
|
+
createElement(PreviewPageReloader, {
|
|
179
|
+
locale: doc.locale,
|
|
180
|
+
type: doc.type,
|
|
181
|
+
slug: doc.slug,
|
|
182
|
+
}),
|
|
183
|
+
body,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const layout = options.layouts?.[doc.type];
|
|
187
|
+
const laidOut = layout ? layout(doc, body) : body;
|
|
225
188
|
if (options.render) return options.render(doc, laidOut);
|
|
226
189
|
if (layout || isPage) return laidOut;
|
|
227
190
|
return createElement("article", null, laidOut);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function loadPreviewDoc(
|
|
194
|
+
hit: RouteHit,
|
|
195
|
+
preview: Extract<PreviewState, { enabled: true }>,
|
|
196
|
+
): Promise<ReactNode> {
|
|
197
|
+
if (preview.error) return previewUnavailable(preview.error);
|
|
198
|
+
try {
|
|
199
|
+
const getDoc =
|
|
200
|
+
options.preview?.getDoc ??
|
|
201
|
+
((ref) =>
|
|
202
|
+
fetchPreviewDoc(
|
|
203
|
+
{
|
|
204
|
+
apiUrl: cms.config.apiUrl ?? "",
|
|
205
|
+
workspaceId: cms.config.workspaceId ?? "",
|
|
206
|
+
previewToken: cms.config.previewToken ?? "",
|
|
207
|
+
},
|
|
208
|
+
ref,
|
|
209
|
+
));
|
|
210
|
+
const doc = await getDoc({
|
|
211
|
+
locale: hit.locale,
|
|
212
|
+
type: hit.type,
|
|
213
|
+
slug: hit.slug,
|
|
214
|
+
...preview.checkout,
|
|
215
|
+
});
|
|
216
|
+
if (!doc) return previewUnavailable("Document not found");
|
|
217
|
+
return layoutDoc(doc, true);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
return previewUnavailable(err instanceof Error ? err.message : "Failed to load");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const CmsRoute: CatchAllRoute["CmsRoute"] = async ({ params, searchParams }) => {
|
|
224
|
+
const resolved = await params;
|
|
225
|
+
const hit = resolve(resolved[CATCH_ALL_PARAM]);
|
|
226
|
+
if (!hit) return failFor();
|
|
227
|
+
const preview = await resolvePreviewState(cms, options.preview, searchParams);
|
|
228
|
+
if (preview.enabled) return loadPreviewDoc(hit, preview);
|
|
229
|
+
const doc = await getCachedDoc(hit.locale, hit.type, hit.slug);
|
|
230
|
+
if (!doc) return failFor();
|
|
231
|
+
return layoutDoc(doc, false);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const PublishedRoute = async ({
|
|
235
|
+
params,
|
|
236
|
+
}: {
|
|
237
|
+
params: Promise<CatchAllParams> | CatchAllParams;
|
|
238
|
+
searchParams?: Promise<CatchAllSearchParams> | CatchAllSearchParams;
|
|
239
|
+
}) => {
|
|
240
|
+
const resolved = await params;
|
|
241
|
+
const hit = resolve(resolved[CATCH_ALL_PARAM]);
|
|
242
|
+
if (!hit) return failFor();
|
|
243
|
+
const doc = await getCachedDoc(hit.locale, hit.type, hit.slug);
|
|
244
|
+
if (!doc) return failFor();
|
|
245
|
+
return createElement(
|
|
246
|
+
"div",
|
|
247
|
+
{ "data-backmatter-published": "", style: { display: "contents" } },
|
|
248
|
+
await layoutDoc(doc, false),
|
|
249
|
+
);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const PreviewBranch = async ({
|
|
253
|
+
params,
|
|
254
|
+
searchParams,
|
|
255
|
+
}: {
|
|
256
|
+
params: Promise<CatchAllParams> | CatchAllParams;
|
|
257
|
+
searchParams?: Promise<CatchAllSearchParams> | CatchAllSearchParams;
|
|
258
|
+
}) => {
|
|
259
|
+
const resolved = await params;
|
|
260
|
+
const hit = resolve(resolved[CATCH_ALL_PARAM]);
|
|
261
|
+
if (!hit) return null;
|
|
262
|
+
const preview = await resolvePreviewState(cms, options.preview, searchParams);
|
|
263
|
+
if (!preview.enabled) return null;
|
|
264
|
+
return createElement(
|
|
265
|
+
Fragment,
|
|
266
|
+
null,
|
|
267
|
+
createElement("style", {
|
|
268
|
+
dangerouslySetInnerHTML: {
|
|
269
|
+
__html: "[data-backmatter-published]{display:none!important}",
|
|
270
|
+
},
|
|
271
|
+
}),
|
|
272
|
+
await loadPreviewDoc(hit, preview),
|
|
273
|
+
);
|
|
228
274
|
};
|
|
229
275
|
|
|
230
276
|
const Page: CatchAllRoute["Page"] = (props) =>
|
|
231
277
|
createElement(
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
createElement(
|
|
278
|
+
Fragment,
|
|
279
|
+
null,
|
|
280
|
+
createElement(PublishedRoute, props),
|
|
281
|
+
createElement(Suspense, { fallback: null }, createElement(PreviewBranch, props)),
|
|
235
282
|
);
|
|
236
283
|
|
|
237
284
|
const generateMetadata: CatchAllRoute["generateMetadata"] = async ({ params }) => {
|