@backmatter/next 0.0.11 → 0.0.13

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 CHANGED
@@ -180,8 +180,40 @@ function detectSdk(root) {
180
180
  } catch {}
181
181
  return "@backmatter/core/node";
182
182
  }
183
+ function renderCachedCmsClient() {
184
+ return [
185
+ "const raw = createBackmatter({ ...config, models });",
186
+ "",
187
+ "async function getDoc(locale: string, type: string, slug: string) {",
188
+ ' "use cache";',
189
+ ' cacheLife("hours");',
190
+ " cacheTag(...contentCacheTags(locale, type, slug));",
191
+ " return raw.getDoc(locale, type, slug);",
192
+ "}",
193
+ "",
194
+ "async function listDocs(filter?: { type?: string; locale?: string }) {",
195
+ ' "use cache";',
196
+ ' cacheLife("hours");',
197
+ " cacheTag(...listCacheTags(filter?.type));",
198
+ " return raw.listDocs(filter);",
199
+ "}",
200
+ "",
201
+ "/** Typed content client: cms.query.{type}, cms.getDoc, cms.listDocs. */",
202
+ "export const cms = {",
203
+ " ...raw,",
204
+ " getDoc,",
205
+ " listDocs,",
206
+ ` query: createQuery(models, { getDoc, listDocs }),`,
207
+ "};"
208
+ ];
209
+ }
183
210
  function renderModelsBarrel(input) {
211
+ const nextHost = input.sdk === "@backmatter/next";
184
212
  const lines = [GENERATED_HEADER, `import { createBackmatter } from "${input.sdk}";`];
213
+ if (nextHost) {
214
+ lines.push('import { contentCacheTags, createQuery, listCacheTags } from "@backmatter/core";');
215
+ lines.push('import { cacheLife, cacheTag } from "next/cache";');
216
+ }
185
217
  if (input.configFile) {
186
218
  lines.push(`import config from "${importSpecifier(input.outDir, input.configFile)}";`);
187
219
  } else {
@@ -194,8 +226,12 @@ function renderModelsBarrel(input) {
194
226
  lines.push(`export const models = [${input.models.map((m) => m.importName).join(", ")}] as const;`);
195
227
  lines.push("export type Models = typeof models;");
196
228
  lines.push("");
197
- lines.push("/** Typed content client: cms.query.{type}, cms.getDoc, cms.listDocs. */");
198
- lines.push("export const cms = createBackmatter({ ...config, models });");
229
+ if (nextHost) {
230
+ lines.push(...renderCachedCmsClient());
231
+ } else {
232
+ lines.push("/** Typed content client: cms.query.{type}, cms.getDoc, cms.listDocs. */");
233
+ lines.push("export const cms = createBackmatter({ ...config, models });");
234
+ }
199
235
  return `${lines.join(`
200
236
  `)}
201
237
  `;
@@ -395,10 +431,14 @@ function withBackmatter(nextConfig = {}, options = {}) {
395
431
  return cfg.webpack ? cfg.webpack(config, context) : config;
396
432
  };
397
433
  const cacheComponents = cfg.cacheComponents ?? true;
434
+ const transpilePackages = [
435
+ ...new Set([...cfg.transpilePackages ?? [], "@backmatter/next", "@backmatter/core"])
436
+ ];
398
437
  return {
399
438
  ...nextConfig,
400
439
  cacheComponents,
401
440
  partialPrefetching: cfg.partialPrefetching ?? cacheComponents,
441
+ transpilePackages,
402
442
  turbopack,
403
443
  webpack
404
444
  };
package/dist/loader.cjs CHANGED
@@ -174,8 +174,40 @@ function detectSdk(root) {
174
174
  } catch {}
175
175
  return "@backmatter/core/node";
176
176
  }
177
+ function renderCachedCmsClient() {
178
+ return [
179
+ "const raw = createBackmatter({ ...config, models });",
180
+ "",
181
+ "async function getDoc(locale: string, type: string, slug: string) {",
182
+ ' "use cache";',
183
+ ' cacheLife("hours");',
184
+ " cacheTag(...contentCacheTags(locale, type, slug));",
185
+ " return raw.getDoc(locale, type, slug);",
186
+ "}",
187
+ "",
188
+ "async function listDocs(filter?: { type?: string; locale?: string }) {",
189
+ ' "use cache";',
190
+ ' cacheLife("hours");',
191
+ " cacheTag(...listCacheTags(filter?.type));",
192
+ " return raw.listDocs(filter);",
193
+ "}",
194
+ "",
195
+ "/** Typed content client: cms.query.{type}, cms.getDoc, cms.listDocs. */",
196
+ "export const cms = {",
197
+ " ...raw,",
198
+ " getDoc,",
199
+ " listDocs,",
200
+ ` query: createQuery(models, { getDoc, listDocs }),`,
201
+ "};"
202
+ ];
203
+ }
177
204
  function renderModelsBarrel(input) {
205
+ const nextHost = input.sdk === "@backmatter/next";
178
206
  const lines = [GENERATED_HEADER, `import { createBackmatter } from "${input.sdk}";`];
207
+ if (nextHost) {
208
+ lines.push('import { contentCacheTags, createQuery, listCacheTags } from "@backmatter/core";');
209
+ lines.push('import { cacheLife, cacheTag } from "next/cache";');
210
+ }
179
211
  if (input.configFile) {
180
212
  lines.push(`import config from "${importSpecifier(input.outDir, input.configFile)}";`);
181
213
  } else {
@@ -188,8 +220,12 @@ function renderModelsBarrel(input) {
188
220
  lines.push(`export const models = [${input.models.map((m) => m.importName).join(", ")}] as const;`);
189
221
  lines.push("export type Models = typeof models;");
190
222
  lines.push("");
191
- lines.push("/** Typed content client: cms.query.{type}, cms.getDoc, cms.listDocs. */");
192
- lines.push("export const cms = createBackmatter({ ...config, models });");
223
+ if (nextHost) {
224
+ lines.push(...renderCachedCmsClient());
225
+ } else {
226
+ lines.push("/** Typed content client: cms.query.{type}, cms.getDoc, cms.listDocs. */");
227
+ lines.push("export const cms = createBackmatter({ ...config, models });");
228
+ }
193
229
  return `${lines.join(`
194
230
  `)}
195
231
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backmatter/next",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
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.10",
41
+ "@backmatter/core": "^0.0.12",
42
42
  "@mdx-js/mdx": "^3.1.1",
43
43
  "remark-gfm": "^4.0.1"
44
44
  },
package/src/cached-doc.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // packages/next/src/cached-doc.ts
2
- import { contentCacheTags, type BackmatterDoc } from "@backmatter/core";
2
+ import { contentCacheTags, listCacheTags, type BackmatterDoc, type BackmatterDocSummary } from "@backmatter/core";
3
3
 
4
4
  export type CacheFns = {
5
5
  cacheLife: (profile: string) => void;
@@ -12,13 +12,23 @@ export type GetDocFn = (
12
12
  slug: string,
13
13
  ) => Promise<BackmatterDoc | null>;
14
14
 
15
+ export type ListDocsFn = (filter?: {
16
+ type?: string;
17
+ locale?: string;
18
+ }) => Promise<BackmatterDocSummary[]>;
19
+
15
20
  let registeredGetDoc: GetDocFn | null = null;
21
+ let registeredListDocs: ListDocsFn | null = null;
16
22
 
17
- /** Called from `createCatchAll` so `"use cache"` only closes over serializable args. */
23
+ /** Called from `createBackmatter` so `"use cache"` only closes over serializable args. */
18
24
  export function registerCachedDocGet(getDoc: GetDocFn): void {
19
25
  registeredGetDoc = getDoc;
20
26
  }
21
27
 
28
+ export function registerCachedListDocs(listDocs: ListDocsFn): void {
29
+ registeredListDocs = listDocs;
30
+ }
31
+
22
32
  async function loadCacheFns(): Promise<CacheFns> {
23
33
  try {
24
34
  const mod = (await import("next/cache" as string)) as CacheFns;
@@ -49,7 +59,34 @@ export async function getCachedDoc(
49
59
  fns.cacheTag(...contentCacheTags(locale, type, slug));
50
60
  const getDoc = registeredGetDoc;
51
61
  if (!getDoc) {
52
- throw new Error("[backmatter] getCachedDoc called before createCatchAll registered getDoc");
62
+ throw new Error("[backmatter] getCachedDoc called before createBackmatter registered getDoc");
53
63
  }
54
64
  return getDoc(locale, type, slug);
55
65
  }
66
+
67
+ export async function getCachedDocs(
68
+ type?: string,
69
+ locale?: string,
70
+ cache?: CacheFns,
71
+ ): Promise<BackmatterDocSummary[]> {
72
+ "use cache";
73
+ const fns = cache ?? (await loadCacheFns());
74
+ fns.cacheLife("hours");
75
+ fns.cacheTag(...listCacheTags(type));
76
+ const listDocs = registeredListDocs;
77
+ if (!listDocs) {
78
+ throw new Error("[backmatter] getCachedDocs called before createBackmatter registered listDocs");
79
+ }
80
+ return listDocs({ type, locale });
81
+ }
82
+
83
+ /** Unwrap Next `params` inside `"use cache"` so Cache Components can prerender. */
84
+ export async function cachedRouteParams<T extends object>(
85
+ params: Promise<T> | T,
86
+ cache?: CacheFns,
87
+ ): Promise<T> {
88
+ "use cache";
89
+ const fns = cache ?? (await loadCacheFns());
90
+ fns.cacheLife("hours");
91
+ return await params;
92
+ }
package/src/config.ts CHANGED
@@ -43,6 +43,7 @@ type TurbopackRule = { loaders: Array<{ loader: string; options: Record<string,
43
43
  export type NextConfigLike = {
44
44
  cacheComponents?: boolean;
45
45
  partialPrefetching?: boolean | "unstable_eager";
46
+ transpilePackages?: string[];
46
47
  turbopack?: Record<string, unknown>;
47
48
  webpack?: ((config: WebpackConfigLike, context: any) => WebpackConfigLike) | null;
48
49
  } & Record<string, unknown>;
@@ -119,6 +120,7 @@ export function registryMatchers(loaderOptions: BackmatterLoaderOptions): { glob
119
120
  export type WithBackmatterResult<T extends object> = Omit<T, "turbopack" | "webpack"> & {
120
121
  cacheComponents: boolean;
121
122
  partialPrefetching: boolean | "unstable_eager";
123
+ transpilePackages: string[];
122
124
  turbopack: Record<string, unknown>;
123
125
  webpack: NonNullable<NextConfigLike["webpack"]>;
124
126
  };
@@ -150,10 +152,14 @@ export function withBackmatter<T extends object>(
150
152
  };
151
153
 
152
154
  const cacheComponents = cfg.cacheComponents ?? true;
155
+ const transpilePackages = [
156
+ ...new Set([...(cfg.transpilePackages ?? []), "@backmatter/next", "@backmatter/core"]),
157
+ ];
153
158
  return {
154
159
  ...nextConfig,
155
160
  cacheComponents,
156
161
  partialPrefetching: cfg.partialPrefetching ?? cacheComponents,
162
+ transpilePackages,
157
163
  turbopack,
158
164
  webpack,
159
165
  };
@@ -1,15 +1,29 @@
1
1
  // packages/next/src/create-backmatter.ts
2
2
  // Next hosts cache API fetches with tags so `content.committed` can
3
3
  // `revalidateTag("backmatter")`. Core/CLI keep `cache: "no-store"`.
4
+ // getDoc/listDocs also go through `"use cache"` so Cache Components can prerender.
5
+ import { createQuery, type ModelDef } from "@backmatter/core";
4
6
  import {
5
7
  createBackmatter as createCore,
6
8
  type CreateBackmatterOptions,
7
9
  type BackmatterClient,
8
10
  } from "@backmatter/core/node/create-backmatter";
9
- import type { ModelDef } from "@backmatter/core";
11
+ import { getCachedDoc, getCachedDocs, registerCachedDocGet, registerCachedListDocs } from "./cached-doc";
10
12
 
11
13
  export function createBackmatter<const Models extends readonly ModelDef[]>(
12
14
  options: CreateBackmatterOptions<Models>,
13
15
  ): BackmatterClient<Models> {
14
- return createCore({ ...options, taggedApiFetch: true });
16
+ const core = createCore({ ...options, taggedApiFetch: true });
17
+ registerCachedDocGet(core.getDoc);
18
+ registerCachedListDocs(core.listDocs);
19
+ const getDoc: BackmatterClient<Models>["getDoc"] = (locale, type, slug) =>
20
+ getCachedDoc(locale, type, slug);
21
+ const listDocs: BackmatterClient<Models>["listDocs"] = (filter) =>
22
+ getCachedDocs(filter?.type, filter?.locale);
23
+ return {
24
+ ...core,
25
+ getDoc,
26
+ listDocs,
27
+ query: createQuery(options.models, { getDoc, listDocs }),
28
+ };
15
29
  }
@@ -16,7 +16,7 @@ export type DevModeState =
16
16
  | { status: "hidden" }
17
17
  | {
18
18
  status: "ready";
19
- user: CmsSessionUser;
19
+ user?: CmsSessionUser;
20
20
  mode: "preview" | "live";
21
21
  branch?: string;
22
22
  tree?: string;
@@ -26,28 +26,37 @@ export function devModeState(
26
26
  user: CmsSessionUser | null,
27
27
  session: { enabled: boolean; branch?: string; tree?: string },
28
28
  ): DevModeState {
29
- if (!user) return { status: "hidden" };
29
+ if (!user && !session.enabled) return { status: "hidden" };
30
30
  const resolved = resolveDevMode(session);
31
31
  return {
32
32
  status: "ready",
33
- user,
33
+ ...(user ? { user } : {}),
34
34
  ...resolved,
35
35
  };
36
36
  }
37
37
 
38
+ export async function loadDevModeState(
39
+ apiUrl: string,
40
+ fetchUser: typeof fetchCmsSession = fetchCmsSession,
41
+ fetchSession: typeof fetchHostPreviewSession = fetchHostPreviewSession,
42
+ ): Promise<DevModeState> {
43
+ const trimmed = apiUrl.trim();
44
+ const [user, session] = await Promise.all([
45
+ trimmed ? fetchUser(trimmed) : Promise.resolve(null),
46
+ fetchSession(""),
47
+ ]);
48
+ return devModeState(user, session);
49
+ }
50
+
38
51
  export function useDevMode(options: { apiUrl: string }): DevModeState {
39
52
  const [state, setState] = useState<DevModeState>({ status: "hidden" });
40
53
 
41
54
  useEffect(() => {
42
- const apiUrl = options.apiUrl.trim();
43
- if (!apiUrl) return;
44
55
  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
- );
56
+ void loadDevModeState(options.apiUrl).then((next) => {
57
+ if (cancelled) return;
58
+ setState(next);
59
+ });
51
60
  return () => {
52
61
  cancelled = true;
53
62
  };
package/src/devbar.tsx CHANGED
@@ -7,6 +7,7 @@ export {
7
7
  DevBarView,
8
8
  devModeState,
9
9
  exitHostPreview,
10
+ loadDevModeState,
10
11
  useDevMode,
11
12
  type CmsSessionUser,
12
13
  type DevModeState,
@@ -15,6 +15,7 @@ import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
15
15
  export type PreviewRouteDeps = {
16
16
  enableDraft?: () => void | Promise<void>;
17
17
  disableDraft?: () => void | Promise<void>;
18
+ isDraftEnabled?: () => boolean | Promise<boolean>;
18
19
  now?: () => number;
19
20
  };
20
21
 
@@ -45,6 +46,13 @@ async function nextDraft(enable: boolean): Promise<void> {
45
46
  else draft.disable();
46
47
  }
47
48
 
49
+ async function nextDraftEnabled(): Promise<boolean> {
50
+ const mod = (await import("next/headers" as string)) as {
51
+ draftMode: () => Promise<{ isEnabled: boolean }>;
52
+ };
53
+ return (await mod.draftMode()).isEnabled;
54
+ }
55
+
48
56
  function cookieFrom(req: Request): string | undefined {
49
57
  const header = req.headers.get("cookie");
50
58
  if (!header) return undefined;
@@ -75,11 +83,14 @@ export function createPreviewRoute(
75
83
 
76
84
  if (url.searchParams.get("session") === "1") {
77
85
  const checkout = secret ? await checkoutFrom(req, secret, url) : null;
78
- if (!checkout) return Response.json({ mode: "live" });
79
- return Response.json({
80
- mode: "preview",
81
- ...checkout,
82
- });
86
+ if (checkout) {
87
+ return Response.json({
88
+ mode: "preview",
89
+ ...checkout,
90
+ });
91
+ }
92
+ const draftOn = await (deps.isDraftEnabled ?? nextDraftEnabled)();
93
+ return Response.json({ mode: draftOn ? "preview" : "live" });
83
94
  }
84
95
 
85
96
  if (url.searchParams.get("stream") === "1") {
package/src/routes.ts CHANGED
@@ -8,7 +8,7 @@ import { matchRoute, modelFormat, PREVIEW_CHECKOUT_QUERY, PREVIEW_COOKIE, readPr
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
- import { getCachedDoc, registerCachedDocGet } from "./cached-doc";
11
+ import { cachedRouteParams } from "./cached-doc";
12
12
  import { renderPage, type BlockComponents, type BlockFallbacks } from "./page-render";
13
13
  import { previewUnavailable } from "./preview-ui";
14
14
 
@@ -44,6 +44,7 @@ export type CatchAllOptions = {
44
44
  preview?: {
45
45
  enabled: boolean;
46
46
  checkout?: { branch?: string; tree?: string };
47
+ error?: string;
47
48
  getDoc?: (ref: {
48
49
  locale: string;
49
50
  type: string;
@@ -87,6 +88,13 @@ type PreviewState =
87
88
  | { enabled: false }
88
89
  | { enabled: true; checkout: { branch?: string; tree?: string }; error?: string };
89
90
 
91
+ /** Draft without a readable checkout stays on the published page. */
92
+ export function previewReplacesPublished(
93
+ preview: PreviewState,
94
+ ): preview is Extract<PreviewState, { enabled: true }> {
95
+ return preview.enabled && preview.error !== "invalid_session";
96
+ }
97
+
90
98
  type RouteHit = { locale: string; type: string; slug: string };
91
99
 
92
100
  async function checkoutQueryToken(
@@ -107,7 +115,11 @@ async function resolvePreviewState(
107
115
  ): Promise<PreviewState> {
108
116
  if (injected) {
109
117
  if (!injected.enabled) return { enabled: false };
110
- return { enabled: true, checkout: injected.checkout ?? {} };
118
+ return {
119
+ enabled: true,
120
+ checkout: injected.checkout ?? {},
121
+ ...(injected.error ? { error: injected.error } : {}),
122
+ };
111
123
  }
112
124
  try {
113
125
  const mod = (await import("next/headers" as string)) as {
@@ -133,7 +145,6 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
133
145
  cms: BackmatterClient<Models>,
134
146
  options: CatchAllOptions = {},
135
147
  ): CatchAllRoute {
136
- registerCachedDocGet((locale, type, slug) => cms.getDoc(locale, type, slug));
137
148
  const models = cms.models as readonly ModelDef[];
138
149
  const byType = new Map(models.map((m) => [m.type, m]));
139
150
  const matchOptions = {
@@ -221,12 +232,12 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
221
232
  }
222
233
 
223
234
  const CmsRoute: CatchAllRoute["CmsRoute"] = async ({ params, searchParams }) => {
224
- const resolved = await params;
235
+ const resolved = await cachedRouteParams(params);
225
236
  const hit = resolve(resolved[CATCH_ALL_PARAM]);
226
237
  if (!hit) return failFor();
227
238
  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);
239
+ if (previewReplacesPublished(preview)) return loadPreviewDoc(hit, preview);
240
+ const doc = await cms.getDoc(hit.locale, hit.type, hit.slug);
230
241
  if (!doc) return failFor();
231
242
  return layoutDoc(doc, false);
232
243
  };
@@ -237,10 +248,10 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
237
248
  params: Promise<CatchAllParams> | CatchAllParams;
238
249
  searchParams?: Promise<CatchAllSearchParams> | CatchAllSearchParams;
239
250
  }) => {
240
- const resolved = await params;
251
+ const resolved = await cachedRouteParams(params);
241
252
  const hit = resolve(resolved[CATCH_ALL_PARAM]);
242
253
  if (!hit) return failFor();
243
- const doc = await getCachedDoc(hit.locale, hit.type, hit.slug);
254
+ const doc = await cms.getDoc(hit.locale, hit.type, hit.slug);
244
255
  if (!doc) return failFor();
245
256
  return createElement(
246
257
  "div",
@@ -256,11 +267,11 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
256
267
  params: Promise<CatchAllParams> | CatchAllParams;
257
268
  searchParams?: Promise<CatchAllSearchParams> | CatchAllSearchParams;
258
269
  }) => {
259
- const resolved = await params;
270
+ const resolved = await cachedRouteParams(params);
260
271
  const hit = resolve(resolved[CATCH_ALL_PARAM]);
261
272
  if (!hit) return null;
262
273
  const preview = await resolvePreviewState(cms, options.preview, searchParams);
263
- if (!preview.enabled) return null;
274
+ if (!previewReplacesPublished(preview)) return null;
264
275
  return createElement(
265
276
  Fragment,
266
277
  null,
@@ -282,10 +293,10 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
282
293
  );
283
294
 
284
295
  const generateMetadata: CatchAllRoute["generateMetadata"] = async ({ params }) => {
285
- const resolved = await params;
296
+ const resolved = await cachedRouteParams(params);
286
297
  const hit = resolve(resolved[CATCH_ALL_PARAM]);
287
298
  if (!hit) return {};
288
- const doc = await getCachedDoc(hit.locale, hit.type, hit.slug);
299
+ const doc = await cms.getDoc(hit.locale, hit.type, hit.slug);
289
300
  if (!doc) return {};
290
301
  const title = doc.frontmatter.title;
291
302
  return { title: typeof title === "string" ? title : hit.slug };