@backmatter/next 0.0.21 → 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/src/routes.ts CHANGED
@@ -4,19 +4,35 @@
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
6
  import { Fragment, Suspense, createElement, type ReactNode } from "react";
7
- import { isRoutable, matchRoute, modelFormat, PREVIEW_CHECKOUT_QUERY, PREVIEW_COOKIE, readPreviewCookie, routeSegments, type BackmatterDoc, type ModelDef } from "@backmatter/core";
7
+ import {
8
+ DEFAULT_TEMPLATE_SLUG,
9
+ isRoutable,
10
+ isTemplateSlug,
11
+ matchRoute,
12
+ PREVIEW_CHECKOUT_QUERY,
13
+ PREVIEW_COOKIE,
14
+ readPreviewCookie,
15
+ routeSegments,
16
+ type BackmatterDoc,
17
+ type ModelDef,
18
+ type SourceDef,
19
+ } from "@backmatter/core";
8
20
  import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
9
21
  import { fetchPreviewDoc } from "@backmatter/core/preview";
10
- import { renderDoc, type MdxComponents } from "./mdx-compile";
22
+ import type { MdxComponents } from "./mdx-compile";
11
23
  import { cachedRouteParams, getCachedPublished, registerPublishedRender } from "./cached-doc";
12
- import { renderPage, type BlockComponents, type BlockFallbacks } from "./page-render";
24
+ import type { BlockComponents, BlockFallbacks } from "./page-render";
25
+ import { renderLayoutDoc, type DocLayout } from "./layout-doc";
26
+ import { previewContentForCheckout, runWithPreviewContent } from "./preview-content";
13
27
  import { previewUnavailable } from "./preview-ui";
28
+ import { SiteDocBridge } from "./site-doc-bridge";
29
+ import { requireSource, resolveSourceData } from "./source-data";
14
30
 
15
31
  export const CATCH_ALL_PARAM = "backmatter";
16
32
 
17
33
  export type CatchAllParams = { [CATCH_ALL_PARAM]?: string[] };
18
34
 
19
- export type DocLayout = (doc: BackmatterDoc, body: ReactNode) => ReactNode;
35
+ export type { DocLayout };
20
36
 
21
37
  export type CatchAllOptions = {
22
38
  /** MDX component map, usually the generated `components`. */
@@ -27,6 +43,12 @@ export type CatchAllOptions = {
27
43
  fallbacks?: BlockFallbacks;
28
44
  /** Per-type wrappers, usually the generated `layouts`. */
29
45
  layouts?: Record<string, DocLayout>;
46
+ /**
47
+ * External record loaders for `external` models, usually the generated
48
+ * `sources`. The record reaches blocks as `props.data` and layouts as the
49
+ * third argument. A missing record is a 404.
50
+ */
51
+ sources?: Record<string, SourceDef>;
30
52
  /** Overrides `cms.config.locales`. */
31
53
  locales?: readonly string[];
32
54
  /** Overrides `cms.config.defaultLocale`. */
@@ -37,6 +59,23 @@ export type CatchAllOptions = {
37
59
  render?: (doc: BackmatterDoc, body: ReactNode) => ReactNode;
38
60
  /** Hook for tests; defaults to Next's `notFound()`. */
39
61
  notFound?: () => never;
62
+ /**
63
+ * Generated server action (`.backmatter/preview-action`). When set, preview
64
+ * swaps the body from editor messages instead of refreshing the route.
65
+ */
66
+ renderPreview?: (
67
+ ref: {
68
+ locale: string;
69
+ type: string;
70
+ slug: string;
71
+ branch?: string;
72
+ tree?: string;
73
+ },
74
+ doc: { frontmatter: Record<string, unknown>; body: string },
75
+ checkoutToken?: string,
76
+ ) => Promise<{ ok: true; node: ReactNode } | { ok: false; error: string }>;
77
+ /** Origins allowed to push preview messages. Defaults to the embedding page's referrer. */
78
+ dashboardOrigins?: readonly string[];
40
79
  /**
41
80
  * Test/host injection. When omitted, the catch-all reads Next draftMode
42
81
  * and the signed preview cookie. Never call cookies() on the live path.
@@ -160,43 +199,127 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
160
199
  const generateStaticParams: CatchAllRoute["generateStaticParams"] = async () => {
161
200
  const docs = await cms.listDocs();
162
201
  const params: CatchAllParams[] = [];
202
+ const seen = new Set<string>();
203
+ // Keyed on the URL: a route without `{locale}` yields one page per slug.
204
+ const push = (model: ModelDef, locale: string, slug: string) => {
205
+ const segments = routeSegments(model, locale, slug);
206
+ const key = segments.join("/");
207
+ if (seen.has(key)) return;
208
+ seen.add(key);
209
+ params.push({ [CATCH_ALL_PARAM]: segments });
210
+ };
163
211
  for (const doc of docs) {
164
212
  const model = byType.get(doc.type);
165
213
  if (!model || !isRoutable(model)) continue;
166
- params.push({ [CATCH_ALL_PARAM]: routeSegments(model, doc.locale, doc.slug) });
214
+ // The template document has no page of its own.
215
+ if (isTemplateSlug(model, doc.slug)) continue;
216
+ push(model, doc.locale, doc.slug);
217
+ }
218
+ // Catalog records that never got a CMS file still prerender through the template.
219
+ const locales = matchOptions.locales ?? [matchOptions.defaultLocale];
220
+ for (const model of models) {
221
+ if (!isRoutable(model)) continue;
222
+ const list = options.sources?.[model.type]?.list;
223
+ if (!list) continue;
224
+ for (const locale of locales) {
225
+ for (const slug of await list(locale)) push(model, locale, slug);
226
+ }
167
227
  }
168
228
  return params;
169
229
  };
170
230
 
171
231
  const failFor = options.notFound ?? nextNotFound;
172
232
 
173
- async function layoutDoc(doc: BackmatterDoc, previewEnabled: boolean): Promise<ReactNode> {
174
- const model = byType.get(doc.type);
175
- const isPage = Boolean(model && modelFormat(model) === "page");
176
- const rendered = isPage
177
- ? await renderPage(doc, options.blocks, options.fallbacks)
178
- : await renderDoc(doc, { components: options.components });
179
- let body = rendered;
180
- if (previewEnabled) {
181
- // Refresh on the server. Passing the host component map into the client
182
- // preview wrapper is not serializable and crashes the page.
183
- const { PreviewPageReloader } = await import("./preview-refresh");
184
- body = createElement(
185
- "div",
186
- null,
187
- createElement(PreviewPageReloader, {
233
+ type GetDocFn = (locale: string, type: string, slug: string) => Promise<BackmatterDoc | null>;
234
+
235
+ /**
236
+ * Document plus external record for a route hit, or null for a 404.
237
+ * External types: no record → null; no per-slug doc → the template doc.
238
+ * The template slug itself only renders when `allowTemplate` (preview).
239
+ */
240
+ async function loadRouteDoc(
241
+ hit: RouteHit,
242
+ getDoc: GetDocFn,
243
+ allowTemplate: boolean,
244
+ ): Promise<{ doc: BackmatterDoc; data: unknown } | null> {
245
+ const model = byType.get(hit.type);
246
+ if (!model) return null;
247
+ const source = requireSource(options.sources, model);
248
+ const onTemplate = isTemplateSlug(model, hit.slug);
249
+ if (onTemplate && !allowTemplate) return null;
250
+ const resolved = await resolveSourceData(source, model, hit);
251
+ if (!resolved.ok) return null;
252
+ let doc = await getDoc(hit.locale, hit.type, hit.slug);
253
+ if (!doc && model.external && !onTemplate) {
254
+ const template = model.template ?? DEFAULT_TEMPLATE_SLUG;
255
+ doc = await getDoc(hit.locale, hit.type, template);
256
+ if (!doc) {
257
+ console.warn(
258
+ `[backmatter] ${hit.type}/${hit.slug} has a record but no document; add content/${hit.locale}/${hit.type}/${template} to render every ${hit.type}`,
259
+ );
260
+ }
261
+ }
262
+ if (!doc) return null;
263
+ return { doc, data: resolved.data };
264
+ }
265
+
266
+ async function layoutDoc(
267
+ doc: BackmatterDoc,
268
+ preview: { enabled: boolean; branch?: string; tree?: string },
269
+ data?: unknown,
270
+ ): Promise<ReactNode> {
271
+ const { apiUrl, workspaceId, previewToken } = cms.config;
272
+ const content =
273
+ preview.enabled && apiUrl && workspaceId && previewToken
274
+ ? previewContentForCheckout(
275
+ { apiUrl, workspaceId, previewToken },
276
+ { branch: preview.branch, tree: preview.tree },
277
+ )
278
+ : null;
279
+ const inner = await runWithPreviewContent(content, () =>
280
+ renderLayoutDoc(
281
+ doc,
282
+ {
283
+ models,
284
+ components: options.components,
285
+ blocks: options.blocks,
286
+ fallbacks: options.fallbacks,
287
+ layouts: options.layouts,
288
+ sources: options.sources,
289
+ render: options.render,
290
+ },
291
+ data,
292
+ ),
293
+ );
294
+ if (!preview.enabled) return inner;
295
+ if (options.renderPreview) {
296
+ const { PreviewClient } = await import("./preview-refresh");
297
+ return createElement(
298
+ PreviewClient,
299
+ {
188
300
  locale: doc.locale,
189
301
  type: doc.type,
190
302
  slug: doc.slug,
191
- }),
192
- rendered,
303
+ branch: preview.branch,
304
+ tree: preview.tree,
305
+ dashboardOrigins: options.dashboardOrigins ? [...options.dashboardOrigins] : [],
306
+ renderPreview: options.renderPreview,
307
+ },
308
+ inner,
193
309
  );
194
310
  }
195
- const layout = options.layouts?.[doc.type];
196
- const laidOut = layout ? layout(doc, body) : body;
197
- if (options.render) return options.render(doc, laidOut);
198
- if (layout || isPage) return laidOut;
199
- return createElement("article", null, laidOut);
311
+ // Hosts that have not generated `preview-action` keep the refresh fallback.
312
+ const { PreviewPageReloader } = await import("./preview-refresh");
313
+ return createElement(
314
+ "div",
315
+ null,
316
+ createElement(PreviewPageReloader, {
317
+ locale: doc.locale,
318
+ type: doc.type,
319
+ slug: doc.slug,
320
+ }),
321
+ inner,
322
+ );
200
323
  }
201
324
 
202
325
  async function loadPreviewDoc(
@@ -216,34 +339,54 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
216
339
  },
217
340
  ref,
218
341
  ));
219
- const doc = await getDoc({
220
- locale: hit.locale,
221
- type: hit.type,
222
- slug: hit.slug,
223
- ...preview.checkout,
224
- });
225
- if (!doc) return previewUnavailable("Document not found");
226
- return layoutDoc(doc, true);
342
+ const loaded = await loadRouteDoc(
343
+ hit,
344
+ (locale, type, slug) => getDoc({ locale, type, slug, ...preview.checkout }),
345
+ true,
346
+ );
347
+ if (!loaded) return previewUnavailable("Document not found");
348
+ return layoutDoc(loaded.doc, { enabled: true, ...preview.checkout }, loaded.data);
227
349
  } catch (err) {
228
350
  return previewUnavailable(err instanceof Error ? err.message : "Failed to load");
229
351
  }
230
352
  }
231
353
 
232
354
  async function renderPublished(locale: string, type: string, slug: string): Promise<ReactNode> {
233
- const doc = await cms.getDoc(locale, type, slug);
234
- if (!doc) return null;
355
+ const loaded = await loadRouteDoc({ locale, type, slug }, cms.getDoc, false);
356
+ if (!loaded) return null;
235
357
  return createElement(
236
358
  "div",
237
359
  { "data-backmatter-published": "", style: { display: "contents" } },
238
- await layoutDoc(doc, false),
360
+ await layoutDoc(loaded.doc, { enabled: false }, loaded.data),
239
361
  );
240
362
  }
241
363
 
242
364
  async function publishedBody(locale: string, type: string, slug: string): Promise<ReactNode> {
243
365
  registerPublishedRender(renderPublished);
244
366
  const body = await getCachedPublished(locale, type, slug);
245
- if (body == null) return failFor();
246
- return body;
367
+ if (body != null) return body;
368
+ // A tree-only document has no published body. 404 only when preview is off,
369
+ // so the preview branch can still render it. The dynamic read is on the miss path.
370
+ if (options.preview) {
371
+ const state: PreviewState = options.preview.enabled
372
+ ? {
373
+ enabled: true,
374
+ checkout: options.preview.checkout ?? {},
375
+ ...(options.preview.error ? { error: options.preview.error } : {}),
376
+ }
377
+ : { enabled: false };
378
+ if (previewReplacesPublished(state)) return null;
379
+ return failFor();
380
+ }
381
+ try {
382
+ const mod = (await import("next/headers" as string)) as {
383
+ draftMode: () => Promise<{ isEnabled: boolean }>;
384
+ };
385
+ if ((await mod.draftMode()).isEnabled) return null;
386
+ } catch {
387
+ // No Next runtime (tests, static analysis): a miss is a 404.
388
+ }
389
+ return failFor();
247
390
  }
248
391
 
249
392
  const CmsRoute: CatchAllRoute["CmsRoute"] = async ({ params, searchParams }) => {
@@ -264,7 +407,9 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
264
407
  const resolved = await params;
265
408
  const hit = resolve(resolved[CATCH_ALL_PARAM]);
266
409
  if (!hit) return failFor();
267
- return publishedBody(hit.locale, hit.type, hit.slug);
410
+ const body = await publishedBody(hit.locale, hit.type, hit.slug);
411
+ if (body == null) return body;
412
+ return createElement(Fragment, null, docBridge(hit), body);
268
413
  };
269
414
 
270
415
  const PreviewBranch = async ({
@@ -287,10 +432,23 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
287
432
  __html: "[data-backmatter-published]{display:none!important}",
288
433
  },
289
434
  }),
435
+ docBridge(hit),
290
436
  await loadPreviewDoc(hit, preview),
291
437
  );
292
438
  };
293
439
 
440
+ function docBridge(hit: { locale: string; type: string; slug: string }) {
441
+ if (!cms.config.workspaceId) return null;
442
+ return createElement(SiteDocBridge, {
443
+ doc: {
444
+ locale: hit.locale,
445
+ type: hit.type,
446
+ slug: hit.slug,
447
+ workspaceId: cms.config.workspaceId,
448
+ },
449
+ });
450
+ }
451
+
294
452
  const Page: CatchAllRoute["Page"] = (props) =>
295
453
  createElement(
296
454
  Fragment,
@@ -303,10 +461,14 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
303
461
  const resolved = await cachedRouteParams(params);
304
462
  const hit = resolve(resolved[CATCH_ALL_PARAM]);
305
463
  if (!hit) return {};
306
- const doc = await cms.getDoc(hit.locale, hit.type, hit.slug);
307
- if (!doc) return {};
308
- const title = doc.frontmatter.title;
309
- return { title: typeof title === "string" ? title : hit.slug };
464
+ const loaded = await loadRouteDoc(hit, cms.getDoc, false);
465
+ if (!loaded) return {};
466
+ const title = loaded.doc.frontmatter.title;
467
+ if (typeof title === "string" && title.trim()) return { title };
468
+ const source = options.sources?.[hit.type];
469
+ const fromSource =
470
+ source?.metadata && loaded.data != null ? source.metadata(loaded.data).title : undefined;
471
+ return { title: fromSource ?? hit.slug };
310
472
  };
311
473
 
312
474
  return { Page, CmsRoute, generateStaticParams, generateMetadata, resolve };
@@ -0,0 +1,37 @@
1
+ // packages/next/src/site-channel.ts
2
+ // One in-memory preview channel for the on-site editor and the page it edits.
3
+ // The pair lives for the lifetime of the tab. Do not close it.
4
+ import { createLocalPreviewTransport, type PreviewTransport } from "@backmatter/core/preview";
5
+
6
+ const CHANNEL_KEY = "__backmatterSitePreview";
7
+
8
+ type ChannelPair = { editor: PreviewTransport; page: PreviewTransport };
9
+
10
+ function channel(): ChannelPair {
11
+ const host = globalThis as typeof globalThis & { [CHANNEL_KEY]?: ChannelPair };
12
+ if (!host[CHANNEL_KEY]) host[CHANNEL_KEY] = createLocalPreviewTransport();
13
+ return host[CHANNEL_KEY];
14
+ }
15
+
16
+ export function sitePageTransport(): PreviewTransport {
17
+ return channel().page;
18
+ }
19
+
20
+ export function siteEditorTransport(): PreviewTransport {
21
+ return channel().editor;
22
+ }
23
+
24
+ export function fanInPreviewTransports(parts: readonly PreviewTransport[]): PreviewTransport {
25
+ return {
26
+ send(message) {
27
+ for (const part of parts) part.send(message);
28
+ },
29
+ subscribe(listener) {
30
+ const stops = parts.map((part) => part.subscribe(listener));
31
+ return () => {
32
+ for (const stop of stops) stop();
33
+ };
34
+ },
35
+ close() {},
36
+ };
37
+ }
@@ -0,0 +1,50 @@
1
+ // packages/next/src/site-doc-bridge.tsx
2
+ "use client";
3
+
4
+ import { useEffect } from "react";
5
+ import { sameSiteDoc, type SiteDocRef } from "./site-doc";
6
+
7
+ type Bus = {
8
+ current: SiteDocRef | null;
9
+ listeners: Set<(doc: SiteDocRef) => void>;
10
+ };
11
+
12
+ const BUS_KEY = "__backmatterSiteDoc";
13
+
14
+ function bus(): Bus {
15
+ const host = globalThis as typeof globalThis & { [BUS_KEY]?: Bus };
16
+ if (!host[BUS_KEY]) host[BUS_KEY] = { current: null, listeners: new Set() };
17
+ return host[BUS_KEY];
18
+ }
19
+
20
+ export function publishSiteDoc(doc: SiteDocRef) {
21
+ const channel = bus();
22
+ if (sameSiteDoc(channel.current, doc)) return;
23
+ channel.current = doc;
24
+ for (const listener of channel.listeners) listener(doc);
25
+ }
26
+
27
+ export function subscribeSiteDoc(listener: (doc: SiteDocRef) => void): () => void {
28
+ const channel = bus();
29
+ channel.listeners.add(listener);
30
+ if (channel.current) listener(channel.current);
31
+ return () => channel.listeners.delete(listener);
32
+ }
33
+
34
+ /** Rendered by the CMS page so the bar hears the document for the current route. */
35
+ export function SiteDocBridge({ doc }: { doc: SiteDocRef }) {
36
+ useEffect(() => {
37
+ publishSiteDoc(doc);
38
+ }, [doc.locale, doc.type, doc.slug, doc.workspaceId]);
39
+
40
+ return (
41
+ <span
42
+ hidden
43
+ data-backmatter-doc=""
44
+ data-locale={doc.locale}
45
+ data-type={doc.type}
46
+ data-slug={doc.slug}
47
+ data-workspace={doc.workspaceId}
48
+ />
49
+ );
50
+ }
@@ -0,0 +1,123 @@
1
+ // packages/next/src/site-doc.ts
2
+ export type SiteDocRef = {
3
+ locale: string;
4
+ type: string;
5
+ slug: string;
6
+ workspaceId: string;
7
+ };
8
+
9
+ export function parseSiteDoc(content: string | null | undefined): SiteDocRef | null {
10
+ if (!content) return null;
11
+ try {
12
+ const value = JSON.parse(content) as Record<string, unknown>;
13
+ if (
14
+ typeof value.locale !== "string" ||
15
+ typeof value.type !== "string" ||
16
+ typeof value.slug !== "string" ||
17
+ typeof value.workspaceId !== "string" ||
18
+ !value.locale ||
19
+ !value.type ||
20
+ !value.slug ||
21
+ !value.workspaceId
22
+ ) {
23
+ return null;
24
+ }
25
+ return {
26
+ locale: value.locale,
27
+ type: value.type,
28
+ slug: value.slug,
29
+ workspaceId: value.workspaceId,
30
+ };
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ export function readSiteDoc(): SiteDocRef | null {
37
+ if (typeof document === "undefined") return null;
38
+ const marked = document.querySelector("[data-backmatter-doc]");
39
+ if (marked) {
40
+ const doc = {
41
+ locale: marked.getAttribute("data-locale") ?? "",
42
+ type: marked.getAttribute("data-type") ?? "",
43
+ slug: marked.getAttribute("data-slug") ?? "",
44
+ workspaceId: marked.getAttribute("data-workspace") ?? "",
45
+ };
46
+ if (doc.locale && doc.type && doc.slug && doc.workspaceId) return doc;
47
+ }
48
+ return parseSiteDoc(document.querySelector('meta[name="backmatter:doc"]')?.getAttribute("content"));
49
+ }
50
+
51
+ export function sameSiteDoc(a: SiteDocRef | null, b: SiteDocRef | null): boolean {
52
+ if (a === b) return true;
53
+ if (!a || !b) return false;
54
+ return (
55
+ a.locale === b.locale &&
56
+ a.type === b.type &&
57
+ a.slug === b.slug &&
58
+ a.workspaceId === b.workspaceId
59
+ );
60
+ }
61
+
62
+ export type SiteBlock = {
63
+ id: string;
64
+ type: string;
65
+ props: Record<string, unknown>;
66
+ };
67
+
68
+ export function siteBlocks(value: unknown): SiteBlock[] {
69
+ if (!Array.isArray(value)) return [];
70
+ return value.filter((row): row is SiteBlock => {
71
+ if (!row || typeof row !== "object" || Array.isArray(row)) return false;
72
+ const block = row as Record<string, unknown>;
73
+ return (
74
+ typeof block.id === "string" &&
75
+ typeof block.type === "string" &&
76
+ !!block.props &&
77
+ typeof block.props === "object" &&
78
+ !Array.isArray(block.props)
79
+ );
80
+ });
81
+ }
82
+
83
+ export function moveSiteBlock(blocks: SiteBlock[], id: string, direction: -1 | 1): SiteBlock[] {
84
+ const at = blocks.findIndex((block) => block.id === id);
85
+ const next = at + direction;
86
+ if (at < 0 || next < 0 || next >= blocks.length) return blocks;
87
+ const copy = [...blocks];
88
+ const [row] = copy.splice(at, 1);
89
+ copy.splice(next, 0, row!);
90
+ return copy;
91
+ }
92
+
93
+ export function patchSiteBlock(
94
+ blocks: SiteBlock[],
95
+ id: string,
96
+ prop: string,
97
+ value: unknown,
98
+ ): SiteBlock[] {
99
+ return blocks.map((block) =>
100
+ block.id === id ? { ...block, props: { ...block.props, [prop]: value } } : block,
101
+ );
102
+ }
103
+
104
+ /** Append text from an AI SDK UI message stream chunk. Incomplete lines stay buffered. */
105
+ export function takeChatDeltas(buffer: string, chunk: string): { text: string; rest: string } {
106
+ const combined = buffer + chunk;
107
+ const lines = combined.split("\n");
108
+ const rest = lines.pop() ?? "";
109
+ let text = "";
110
+ for (const line of lines) {
111
+ const trimmed = line.trim();
112
+ if (!trimmed.startsWith("data:")) continue;
113
+ const payload = trimmed.slice(5).trim();
114
+ if (!payload || payload === "[DONE]") continue;
115
+ try {
116
+ const event = JSON.parse(payload) as { type?: string; delta?: string };
117
+ if (event.type === "text-delta" && typeof event.delta === "string") text += event.delta;
118
+ } catch {
119
+ // a non-JSON event is not assistant text
120
+ }
121
+ }
122
+ return { text, rest };
123
+ }