@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.
package/dist/config.cjs CHANGED
@@ -87,7 +87,8 @@ var DEFAULT_DIRS = {
87
87
  richtext: "richtext",
88
88
  mdx: "mdx",
89
89
  layouts: "layouts",
90
- objects: "objects"
90
+ objects: "objects",
91
+ sources: "sources"
91
92
  };
92
93
 
93
94
  // ../core/src/node/discover.ts
@@ -151,6 +152,9 @@ function listLayoutFiles(root, layoutsDir = "layouts") {
151
152
  function listObjectFiles(root, objectsDir = "objects") {
152
153
  return listModelFiles(root, objectsDir);
153
154
  }
155
+ function listSourceDefFiles(root, sourcesDir = "sources") {
156
+ return listModelFiles(root, sourcesDir);
157
+ }
154
158
  function listBlockFiles(root, blocksDir = "blocks") {
155
159
  const { files, skipped } = listPascalCaseFiles(root, blocksDir);
156
160
  return { blocks: files, skipped };
@@ -189,20 +193,32 @@ function renderCachedCmsClient() {
189
193
  return [
190
194
  "const raw = createBackmatter({ ...config, models, sync: false });",
191
195
  "",
192
- "async function getDoc(locale: string, type: string, slug: string) {",
196
+ "async function cachedGetDoc(locale: string, type: string, slug: string) {",
193
197
  ' "use cache";',
194
198
  ' cacheLife("hours");',
195
199
  " cacheTag(...contentCacheTags(locale, type, slug));",
196
200
  " return raw.getDoc(locale, type, slug);",
197
201
  "}",
198
202
  "",
199
- "async function listDocs(filter?: { type?: string; locale?: string }) {",
203
+ "async function cachedListDocs(filter?: { type?: string; locale?: string }) {",
200
204
  ' "use cache";',
201
205
  ' cacheLife("hours");',
202
206
  " cacheTag(...listCacheTags(filter?.type));",
203
207
  " return raw.listDocs(filter);",
204
208
  "}",
205
209
  "",
210
+ "async function getDoc(locale: string, type: string, slug: string) {",
211
+ " const preview = currentPreviewContent();",
212
+ " if (preview) return preview.getDoc(locale, type, slug);",
213
+ " return cachedGetDoc(locale, type, slug);",
214
+ "}",
215
+ "",
216
+ "async function listDocs(filter?: { type?: string; locale?: string }) {",
217
+ " const preview = currentPreviewContent();",
218
+ " if (preview) return preview.listDocs(filter);",
219
+ " return cachedListDocs(filter);",
220
+ "}",
221
+ "",
206
222
  "/** Typed content client: cms.query.{type}, cms.getDoc, cms.listDocs. */",
207
223
  "export const cms = {",
208
224
  " ...raw,",
@@ -217,6 +233,7 @@ function renderModelsBarrel(input) {
217
233
  const lines = [GENERATED_HEADER, `import { createBackmatter } from "${input.sdk}";`];
218
234
  if (nextHost) {
219
235
  lines.push('import { contentCacheTags, createQuery, listCacheTags } from "@backmatter/core";');
236
+ lines.push('import { currentPreviewContent } from "@backmatter/next/preview-content";');
220
237
  lines.push('import { cacheLife, cacheTag } from "next/cache";');
221
238
  }
222
239
  if (input.configFile) {
@@ -325,6 +342,46 @@ function renderBlockFallbacksBarrel(input) {
325
342
  `)}
326
343
  `;
327
344
  }
345
+ function renderPreviewAction(sdk) {
346
+ if (sdk !== "@backmatter/next") {
347
+ return `${GENERATED_HEADER}
348
+ /** Instant preview needs @backmatter/next. This host has no server action. */
349
+ export const renderPreview = null;
350
+ `;
351
+ }
352
+ return `"use server";
353
+ ${GENERATED_HEADER}
354
+ import { createRenderPreview } from "@backmatter/next/render-preview";
355
+ import { cms } from "./index";
356
+ import { blockLoaders } from "./blocks";
357
+ import { layouts } from "./layouts";
358
+ import { sources } from "./sources";
359
+ import { components as mdxComponents } from "./mdx";
360
+ import { richtextComponents } from "./richtext";
361
+
362
+ const components = { ...richtextComponents, ...mdxComponents };
363
+
364
+ export const renderPreview = createRenderPreview(cms, {
365
+ components,
366
+ blocks: blockLoaders,
367
+ layouts,
368
+ sources,
369
+ });
370
+ `;
371
+ }
372
+ function renderSourcesBarrel(input) {
373
+ const lines = [GENERATED_HEADER];
374
+ for (const source of input.sources) {
375
+ lines.push(`import ${source.importName} from "${importSpecifier(input.outDir, source.file)}";`);
376
+ }
377
+ lines.push("");
378
+ lines.push("/** External record loaders: sources/{type}.ts resolves that model's route data. */");
379
+ const entries = input.sources.map((s) => s.importName === s.stem ? s.stem : `${JSON.stringify(s.stem)}: ${s.importName}`);
380
+ lines.push(`export const sources = ${entries.length ? `{ ${entries.join(", ")} }` : "{}"};`);
381
+ return `${lines.join(`
382
+ `)}
383
+ `;
384
+ }
328
385
  function renderLayoutsBarrel(input) {
329
386
  const lines = [GENERATED_HEADER];
330
387
  for (const layout of input.layouts) {
@@ -348,7 +405,9 @@ var REGISTRY_FILES = [
348
405
  "mdx.ts",
349
406
  "richtext.ts",
350
407
  "layouts.ts",
351
- "sync.ts"
408
+ "sources.ts",
409
+ "sync.ts",
410
+ "preview-action.ts"
352
411
  ];
353
412
  function generate(options) {
354
413
  const root = import_node_path2.default.resolve(options.root);
@@ -360,6 +419,7 @@ function generate(options) {
360
419
  const { blocks: mdx, skipped: skippedMdx } = listMdxFiles(root, options.mdxDir ?? DEFAULT_DIRS.mdx);
361
420
  const layouts = listLayoutFiles(root, options.layoutsDir ?? DEFAULT_DIRS.layouts);
362
421
  const objects = listObjectFiles(root, options.objectsDir ?? DEFAULT_DIRS.objects);
422
+ const sources = listSourceDefFiles(root, options.sourcesDir ?? DEFAULT_DIRS.sources);
363
423
  const sdk = options.sdk ?? detectSdk(root);
364
424
  const configFile = findConfigFile(root);
365
425
  const write = options.write ?? true;
@@ -370,7 +430,9 @@ function generate(options) {
370
430
  "mdx.ts": renderMdxBarrel({ outDir, mdx }),
371
431
  "richtext.ts": renderRichtextBarrel({ outDir, richtext }),
372
432
  "layouts.ts": renderLayoutsBarrel({ outDir, layouts }),
373
- "sync.ts": renderSyncBarrel({ outDir, models, blocks, richtext, objects, configFile, sdk })
433
+ "sources.ts": renderSourcesBarrel({ outDir, sources }),
434
+ "sync.ts": renderSyncBarrel({ outDir, models, blocks, richtext, objects, configFile, sdk }),
435
+ "preview-action.ts": renderPreviewAction(sdk)
374
436
  };
375
437
  const outputs = REGISTRY_FILES.map((name) => {
376
438
  const file = import_node_path2.default.join(outDir, name);
@@ -392,6 +454,7 @@ function generate(options) {
392
454
  mdx,
393
455
  layouts,
394
456
  objects,
457
+ sources,
395
458
  skippedBlocks: [...skippedBlocks, ...skippedRichtext],
396
459
  skippedMdx,
397
460
  configFile
@@ -710,6 +773,7 @@ function resolveLoaderOptions(options = {}) {
710
773
  mdxDir: options.mdxDir ?? DEFAULT_DIRS.mdx,
711
774
  layoutsDir: options.layoutsDir ?? DEFAULT_DIRS.layouts,
712
775
  objectsDir: options.objectsDir ?? DEFAULT_DIRS.objects,
776
+ sourcesDir: options.sourcesDir ?? DEFAULT_DIRS.sources,
713
777
  sdk: SDK
714
778
  };
715
779
  }
@@ -723,9 +787,10 @@ function generateRegistry(options = {}) {
723
787
  function registryMatchers(loaderOptions) {
724
788
  const rel = import_node_path4.default.relative(loaderOptions.root, import_node_path4.default.resolve(loaderOptions.root, loaderOptions.outDir)).split(import_node_path4.default.sep).join("/");
725
789
  const escaped = rel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
790
+ const names = REGISTRY_FILES.map((file) => file.replace(/\.ts$/, "")).join("|");
726
791
  return {
727
792
  glob: `**/${rel}/*.ts`,
728
- test: new RegExp(`(^|[\\\\/])${escaped.replace(/\//g, "[\\\\/]")}[\\\\/](index|blocks|block-fallbacks|mdx|richtext|layouts|sync)\\.ts$`)
793
+ test: new RegExp(`(^|[\\\\/])${escaped.replace(/\//g, "[\\\\/]")}[\\\\/](${names})\\.ts$`)
729
794
  };
730
795
  }
731
796
  function withBackmatter(nextConfig = {}, options = {}) {
package/dist/loader.cjs CHANGED
@@ -81,7 +81,8 @@ var DEFAULT_DIRS = {
81
81
  richtext: "richtext",
82
82
  mdx: "mdx",
83
83
  layouts: "layouts",
84
- objects: "objects"
84
+ objects: "objects",
85
+ sources: "sources"
85
86
  };
86
87
 
87
88
  // ../core/src/node/discover.ts
@@ -145,6 +146,9 @@ function listLayoutFiles(root, layoutsDir = "layouts") {
145
146
  function listObjectFiles(root, objectsDir = "objects") {
146
147
  return listModelFiles(root, objectsDir);
147
148
  }
149
+ function listSourceDefFiles(root, sourcesDir = "sources") {
150
+ return listModelFiles(root, sourcesDir);
151
+ }
148
152
  function listBlockFiles(root, blocksDir = "blocks") {
149
153
  const { files, skipped } = listPascalCaseFiles(root, blocksDir);
150
154
  return { blocks: files, skipped };
@@ -183,20 +187,32 @@ function renderCachedCmsClient() {
183
187
  return [
184
188
  "const raw = createBackmatter({ ...config, models, sync: false });",
185
189
  "",
186
- "async function getDoc(locale: string, type: string, slug: string) {",
190
+ "async function cachedGetDoc(locale: string, type: string, slug: string) {",
187
191
  ' "use cache";',
188
192
  ' cacheLife("hours");',
189
193
  " cacheTag(...contentCacheTags(locale, type, slug));",
190
194
  " return raw.getDoc(locale, type, slug);",
191
195
  "}",
192
196
  "",
193
- "async function listDocs(filter?: { type?: string; locale?: string }) {",
197
+ "async function cachedListDocs(filter?: { type?: string; locale?: string }) {",
194
198
  ' "use cache";',
195
199
  ' cacheLife("hours");',
196
200
  " cacheTag(...listCacheTags(filter?.type));",
197
201
  " return raw.listDocs(filter);",
198
202
  "}",
199
203
  "",
204
+ "async function getDoc(locale: string, type: string, slug: string) {",
205
+ " const preview = currentPreviewContent();",
206
+ " if (preview) return preview.getDoc(locale, type, slug);",
207
+ " return cachedGetDoc(locale, type, slug);",
208
+ "}",
209
+ "",
210
+ "async function listDocs(filter?: { type?: string; locale?: string }) {",
211
+ " const preview = currentPreviewContent();",
212
+ " if (preview) return preview.listDocs(filter);",
213
+ " return cachedListDocs(filter);",
214
+ "}",
215
+ "",
200
216
  "/** Typed content client: cms.query.{type}, cms.getDoc, cms.listDocs. */",
201
217
  "export const cms = {",
202
218
  " ...raw,",
@@ -211,6 +227,7 @@ function renderModelsBarrel(input) {
211
227
  const lines = [GENERATED_HEADER, `import { createBackmatter } from "${input.sdk}";`];
212
228
  if (nextHost) {
213
229
  lines.push('import { contentCacheTags, createQuery, listCacheTags } from "@backmatter/core";');
230
+ lines.push('import { currentPreviewContent } from "@backmatter/next/preview-content";');
214
231
  lines.push('import { cacheLife, cacheTag } from "next/cache";');
215
232
  }
216
233
  if (input.configFile) {
@@ -319,6 +336,46 @@ function renderBlockFallbacksBarrel(input) {
319
336
  `)}
320
337
  `;
321
338
  }
339
+ function renderPreviewAction(sdk) {
340
+ if (sdk !== "@backmatter/next") {
341
+ return `${GENERATED_HEADER}
342
+ /** Instant preview needs @backmatter/next. This host has no server action. */
343
+ export const renderPreview = null;
344
+ `;
345
+ }
346
+ return `"use server";
347
+ ${GENERATED_HEADER}
348
+ import { createRenderPreview } from "@backmatter/next/render-preview";
349
+ import { cms } from "./index";
350
+ import { blockLoaders } from "./blocks";
351
+ import { layouts } from "./layouts";
352
+ import { sources } from "./sources";
353
+ import { components as mdxComponents } from "./mdx";
354
+ import { richtextComponents } from "./richtext";
355
+
356
+ const components = { ...richtextComponents, ...mdxComponents };
357
+
358
+ export const renderPreview = createRenderPreview(cms, {
359
+ components,
360
+ blocks: blockLoaders,
361
+ layouts,
362
+ sources,
363
+ });
364
+ `;
365
+ }
366
+ function renderSourcesBarrel(input) {
367
+ const lines = [GENERATED_HEADER];
368
+ for (const source of input.sources) {
369
+ lines.push(`import ${source.importName} from "${importSpecifier(input.outDir, source.file)}";`);
370
+ }
371
+ lines.push("");
372
+ lines.push("/** External record loaders: sources/{type}.ts resolves that model's route data. */");
373
+ const entries = input.sources.map((s) => s.importName === s.stem ? s.stem : `${JSON.stringify(s.stem)}: ${s.importName}`);
374
+ lines.push(`export const sources = ${entries.length ? `{ ${entries.join(", ")} }` : "{}"};`);
375
+ return `${lines.join(`
376
+ `)}
377
+ `;
378
+ }
322
379
  function renderLayoutsBarrel(input) {
323
380
  const lines = [GENERATED_HEADER];
324
381
  for (const layout of input.layouts) {
@@ -342,7 +399,9 @@ var REGISTRY_FILES = [
342
399
  "mdx.ts",
343
400
  "richtext.ts",
344
401
  "layouts.ts",
345
- "sync.ts"
402
+ "sources.ts",
403
+ "sync.ts",
404
+ "preview-action.ts"
346
405
  ];
347
406
  function generate(options) {
348
407
  const root = import_node_path2.default.resolve(options.root);
@@ -354,6 +413,7 @@ function generate(options) {
354
413
  const { blocks: mdx, skipped: skippedMdx } = listMdxFiles(root, options.mdxDir ?? DEFAULT_DIRS.mdx);
355
414
  const layouts = listLayoutFiles(root, options.layoutsDir ?? DEFAULT_DIRS.layouts);
356
415
  const objects = listObjectFiles(root, options.objectsDir ?? DEFAULT_DIRS.objects);
416
+ const sources = listSourceDefFiles(root, options.sourcesDir ?? DEFAULT_DIRS.sources);
357
417
  const sdk = options.sdk ?? detectSdk(root);
358
418
  const configFile = findConfigFile(root);
359
419
  const write = options.write ?? true;
@@ -364,7 +424,9 @@ function generate(options) {
364
424
  "mdx.ts": renderMdxBarrel({ outDir, mdx }),
365
425
  "richtext.ts": renderRichtextBarrel({ outDir, richtext }),
366
426
  "layouts.ts": renderLayoutsBarrel({ outDir, layouts }),
367
- "sync.ts": renderSyncBarrel({ outDir, models, blocks, richtext, objects, configFile, sdk })
427
+ "sources.ts": renderSourcesBarrel({ outDir, sources }),
428
+ "sync.ts": renderSyncBarrel({ outDir, models, blocks, richtext, objects, configFile, sdk }),
429
+ "preview-action.ts": renderPreviewAction(sdk)
368
430
  };
369
431
  const outputs = REGISTRY_FILES.map((name) => {
370
432
  const file = import_node_path2.default.join(outDir, name);
@@ -386,6 +448,7 @@ function generate(options) {
386
448
  mdx,
387
449
  layouts,
388
450
  objects,
451
+ sources,
389
452
  skippedBlocks: [...skippedBlocks, ...skippedRichtext],
390
453
  skippedMdx,
391
454
  configFile
@@ -402,12 +465,14 @@ function backmatterLoader(source) {
402
465
  const mdxDir = import_node_path3.default.resolve(root, options.mdxDir);
403
466
  const layoutsDir = import_node_path3.default.resolve(root, options.layoutsDir);
404
467
  const objectsDir = import_node_path3.default.resolve(root, options.objectsDir);
468
+ const sourcesDir = import_node_path3.default.resolve(root, options.sourcesDir);
405
469
  this.addContextDependency(modelsDir);
406
470
  this.addContextDependency(blocksDir);
407
471
  this.addContextDependency(richtextDir);
408
472
  this.addContextDependency(mdxDir);
409
473
  this.addContextDependency(layoutsDir);
410
474
  this.addContextDependency(objectsDir);
475
+ this.addContextDependency(sourcesDir);
411
476
  this.cacheable?.(true);
412
477
  const result = generate({
413
478
  root,
@@ -418,6 +483,7 @@ function backmatterLoader(source) {
418
483
  mdxDir: options.mdxDir,
419
484
  layoutsDir: options.layoutsDir,
420
485
  objectsDir: options.objectsDir,
486
+ sourcesDir: options.sourcesDir,
421
487
  sdk: options.sdk
422
488
  });
423
489
  if (result.configFile)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backmatter/next",
3
- "version": "0.0.20",
3
+ "version": "0.0.22",
4
4
  "description": "Experimental Next.js adapter for Backmatter. APIs will break. Not ready for production.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -26,8 +26,11 @@
26
26
  "default": "./dist/loader.cjs"
27
27
  },
28
28
  "./routes": "./src/routes.ts",
29
+ "./render-preview": "./src/render-preview.ts",
30
+ "./preview-content": "./src/preview-content.ts",
29
31
  "./revalidate": "./src/revalidate.ts",
30
32
  "./preview-route": "./src/preview-route.ts",
33
+ "./cms-proxy": "./src/cms-proxy.ts",
31
34
  "./proxy": "./src/proxy.ts",
32
35
  "./middleware": "./src/middleware.ts",
33
36
  "./page": "./src/page-render.ts"
@@ -43,7 +46,7 @@
43
46
  "test": "bun test"
44
47
  },
45
48
  "dependencies": {
46
- "@backmatter/core": "^0.0.17",
49
+ "@backmatter/core": "^0.0.19",
47
50
  "@mdx-js/mdx": "^3.1.1",
48
51
  "remark-gfm": "^4.0.1"
49
52
  },
@@ -0,0 +1,108 @@
1
+ // packages/next/src/cms-proxy.ts
2
+ // Same-origin proxy. The browser calls this with the preview session cookie.
3
+ // The host attaches that cookie as a bearer token and forwards an allow-list.
4
+ import { PREVIEW_SESSION_COOKIE, readPreviewSession } from "@backmatter/core";
5
+ import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
6
+
7
+ const DOC = "/workspaces/[^/]+/docs/[^/]+/[^/]+/[^/]+";
8
+
9
+ const ALLOWED: Array<{ method: string; pattern: RegExp }> = [
10
+ { method: "GET", pattern: /^\/workspaces\/[^/]+\/docs$/ },
11
+ { method: "GET", pattern: new RegExp(`^${DOC}$`) },
12
+ { method: "PUT", pattern: new RegExp(`^${DOC}$`) },
13
+ { method: "DELETE", pattern: new RegExp(`^${DOC}$`) },
14
+ { method: "DELETE", pattern: new RegExp(`^${DOC}/tree$`) },
15
+ { method: "POST", pattern: new RegExp(`^${DOC}/resolve$`) },
16
+ { method: "GET", pattern: /^\/workspaces\/[^/]+\/git$/ },
17
+ { method: "POST", pattern: /^\/workspaces\/[^/]+\/git\/commit$/ },
18
+ { method: "POST", pattern: /^\/workspaces\/[^/]+\/preview-ticket$/ },
19
+ { method: "GET", pattern: /^\/preview\/workspaces\/[^/]+\/docs\/[^/]+\/[^/]+\/[^/]+\/stream$/ },
20
+ { method: "GET", pattern: /^\/preview\/workspaces\/[^/]+\/tree\/stream$/ },
21
+ { method: "GET", pattern: /^\/workspaces\/[^/]+\/ai\/threads\/[^/]+$/ },
22
+ { method: "POST", pattern: /^\/workspaces\/[^/]+\/ai\/threads$/ },
23
+ { method: "POST", pattern: /^\/workspaces\/[^/]+\/ai\/threads\/[^/]+\/messages$/ },
24
+ { method: "GET", pattern: /^\/preview\/session$/ },
25
+ { method: "POST", pattern: /^\/preview\/session\/renew$/ },
26
+ ];
27
+
28
+ export function cmsProxyAllowed(method: string, path: string): boolean {
29
+ return ALLOWED.some((rule) => rule.method === method && rule.pattern.test(path));
30
+ }
31
+
32
+ /** Workspace id in an allow-listed path, when the path names one. */
33
+ export function workspaceIdFromProxyPath(path: string): string | null {
34
+ const match = path.match(/^\/(?:preview\/)?workspaces\/([^/]+)/);
35
+ return match?.[1] ?? null;
36
+ }
37
+
38
+ function cookieValue(header: string | null, name: string): string | undefined {
39
+ if (!header) return undefined;
40
+ for (const part of header.split(";")) {
41
+ const [key, ...rest] = part.trim().split("=");
42
+ if (key === name) return rest.join("=");
43
+ }
44
+ return undefined;
45
+ }
46
+
47
+ export type CmsProxyDeps = {
48
+ fetch?: typeof fetch;
49
+ readSessionToken?: (req: Request) => Promise<string | undefined> | string | undefined;
50
+ };
51
+
52
+ export function createCmsProxy(
53
+ cms: Pick<BackmatterClient, "config">,
54
+ deps: CmsProxyDeps = {},
55
+ ) {
56
+ const forward = deps.fetch ?? fetch;
57
+ const readSessionToken =
58
+ deps.readSessionToken ??
59
+ ((req: Request) => cookieValue(req.headers.get("cookie"), PREVIEW_SESSION_COOKIE));
60
+
61
+ const handle = async (req: Request): Promise<Response> => {
62
+ const secret = cms.config.previewToken;
63
+ const apiUrl = cms.config.apiUrl;
64
+ if (!secret || !apiUrl) return Response.json({ error: "preview_not_configured" }, { status: 503 });
65
+
66
+ const url = new URL(req.url);
67
+ const marker = "/api/backmatter/cms";
68
+ const at = url.pathname.indexOf(marker);
69
+ const path = at === -1 ? "" : url.pathname.slice(at + marker.length) || "/";
70
+ if (!cmsProxyAllowed(req.method, path)) {
71
+ return Response.json({ error: "not_found" }, { status: 404 });
72
+ }
73
+
74
+ const token = await readSessionToken(req);
75
+ const session = await readPreviewSession(secret, token);
76
+ if (!session) return Response.json({ error: "unauthorized" }, { status: 401 });
77
+ const pathWorkspace = workspaceIdFromProxyPath(path);
78
+ if (pathWorkspace && pathWorkspace !== session.workspaceId) {
79
+ return Response.json({ error: "forbidden" }, { status: 403 });
80
+ }
81
+ if (cms.config.workspaceId && session.workspaceId !== cms.config.workspaceId) {
82
+ return Response.json({ error: "forbidden" }, { status: 403 });
83
+ }
84
+
85
+ const upstream = new URL(`${apiUrl.replace(/\/$/, "")}${path}`);
86
+ upstream.search = url.search;
87
+ const headers = new Headers();
88
+ headers.set("Authorization", `Bearer ${token}`);
89
+ const contentType = req.headers.get("content-type");
90
+ if (contentType) headers.set("Content-Type", contentType);
91
+ const accept = req.headers.get("accept");
92
+ if (accept) headers.set("Accept", accept);
93
+
94
+ const hasBody = req.method !== "GET" && req.method !== "HEAD";
95
+ const res = await forward(upstream, {
96
+ method: req.method,
97
+ headers,
98
+ body: hasBody ? await req.arrayBuffer() : undefined,
99
+ });
100
+ const responseHeaders = new Headers();
101
+ const type = res.headers.get("content-type");
102
+ if (type) responseHeaders.set("Content-Type", type);
103
+ responseHeaders.set("Cache-Control", "no-store");
104
+ return new Response(res.body, { status: res.status, headers: responseHeaders });
105
+ };
106
+
107
+ return { GET: handle, POST: handle, PUT: handle, DELETE: handle };
108
+ }
package/src/config.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  DEFAULT_OUT_DIR,
15
15
  detectSdk,
16
16
  generate,
17
+ REGISTRY_FILES,
17
18
  type GenerateResult,
18
19
  } from "@backmatter/core/generate";
19
20
  import {
@@ -33,6 +34,7 @@ export type WithBackmatterOptions = {
33
34
  mdxDir?: string;
34
35
  layoutsDir?: string;
35
36
  objectsDir?: string;
37
+ sourcesDir?: string;
36
38
  /** Called after the boot-time generation. Default: log what changed. */
37
39
  onGenerate?: (result: GenerateResult) => void;
38
40
  };
@@ -92,6 +94,7 @@ export function resolveLoaderOptions(options: WithBackmatterOptions = {}): Backm
92
94
  mdxDir: options.mdxDir ?? DEFAULT_DIRS.mdx,
93
95
  layoutsDir: options.layoutsDir ?? DEFAULT_DIRS.layouts,
94
96
  objectsDir: options.objectsDir ?? DEFAULT_DIRS.objects,
97
+ sourcesDir: options.sourcesDir ?? DEFAULT_DIRS.sources,
95
98
  sdk: SDK,
96
99
  };
97
100
  }
@@ -112,9 +115,12 @@ export function registryMatchers(loaderOptions: BackmatterLoaderOptions): { glob
112
115
  .split(path.sep)
113
116
  .join("/");
114
117
  const escaped = rel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
118
+ const names = REGISTRY_FILES.map((file) => file.replace(/\.ts$/, "")).join("|");
115
119
  return {
116
120
  glob: `**/${rel}/*.ts`,
117
- test: new RegExp(`(^|[\\\\/])${escaped.replace(/\//g, "[\\\\/]")}[\\\\/](index|blocks|block-fallbacks|mdx|richtext|layouts|sync)\\.ts$`),
121
+ test: new RegExp(
122
+ `(^|[\\\\/])${escaped.replace(/\//g, "[\\\\/]")}[\\\\/](${names})\\.ts$`,
123
+ ),
118
124
  };
119
125
  }
120
126
 
@@ -2,6 +2,7 @@
2
2
  "use client";
3
3
 
4
4
  import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
5
+ import { SiteChrome } from "./site-editor";
5
6
  import {
6
7
  clearHostPreviewSession,
7
8
  fetchCmsSession,
@@ -80,7 +81,7 @@ const barStyle: CSSProperties = {
80
81
  alignItems: "center",
81
82
  gap: 10,
82
83
  minHeight: 32,
83
- maxWidth: "min(360px, calc(100vw - 24px))",
84
+ maxWidth: "min(520px, calc(100vw - 24px))",
84
85
  padding: "0 12px",
85
86
  paddingBottom: "env(safe-area-inset-bottom, 0px)",
86
87
  background: "#f4f4f5",
@@ -187,10 +188,12 @@ function Divider() {
187
188
  export function DevBarView({
188
189
  state,
189
190
  onExit,
191
+ onEdit,
190
192
  exiting,
191
193
  }: {
192
194
  state: DevModeState;
193
195
  onExit?: () => void;
196
+ onEdit?: () => void;
194
197
  exiting?: boolean;
195
198
  }) {
196
199
  if (state.status !== "ready") return null;
@@ -240,6 +243,20 @@ export function DevBarView({
240
243
  <Segment>draft</Segment>
241
244
  </>
242
245
  ) : null}
246
+ {!live && onEdit ? (
247
+ <>
248
+ <Divider />
249
+ <button
250
+ type="button"
251
+ className="backmatter-devbar-exit"
252
+ aria-label="Edit this page"
253
+ onClick={() => onEdit()}
254
+ style={exitButtonStyle}
255
+ >
256
+ Edit
257
+ </button>
258
+ </>
259
+ ) : null}
243
260
  {!live ? (
244
261
  <>
245
262
  <Divider />
@@ -262,19 +279,17 @@ export function DevBarView({
262
279
  export function DevBarClient({ apiUrl }: { apiUrl: string }) {
263
280
  const state = useDevMode({ apiUrl });
264
281
  const [exiting, setExiting] = useState(false);
265
- return (
266
- <DevBarView
267
- state={state}
268
- exiting={exiting}
269
- onExit={() => {
270
- if (exiting) return;
271
- setExiting(true);
272
- void exitHostPreview().then((ok) => {
273
- if (!ok) setExiting(false);
274
- });
275
- }}
276
- />
277
- );
282
+ const exit = () => {
283
+ if (exiting) return;
284
+ setExiting(true);
285
+ void exitHostPreview().then((ok) => {
286
+ if (!ok) setExiting(false);
287
+ });
288
+ };
289
+ if (state.status === "ready" && state.mode === "preview") {
290
+ return <SiteChrome branch={state.branch} tree={state.tree} exiting={exiting} onExit={exit} />;
291
+ }
292
+ return <DevBarView state={state} exiting={exiting} onExit={exit} />;
278
293
  }
279
294
 
280
295
  export async function exitHostPreview(
@@ -0,0 +1,38 @@
1
+ // packages/next/src/layout-doc.ts
2
+ // Shared body render for the catch-all and `renderPreview`. No preview chrome.
3
+ // `data` is the external source record already resolved by the caller
4
+ // (see source-data.ts); this module only threads it to blocks and the layout.
5
+ import { createElement, type ReactNode } from "react";
6
+ import { modelFormat, type BackmatterDoc, type ModelDef, type SourceDef } from "@backmatter/core";
7
+ import { renderDoc, type MdxComponents } from "./mdx-compile";
8
+ import { renderPage, type BlockComponents, type BlockFallbacks } from "./page-render";
9
+
10
+ export type DocLayout = (doc: BackmatterDoc, body: ReactNode, data?: unknown) => ReactNode;
11
+
12
+ export type LayoutDocOptions = {
13
+ models: readonly ModelDef[];
14
+ components?: MdxComponents;
15
+ blocks?: BlockComponents;
16
+ fallbacks?: BlockFallbacks;
17
+ layouts?: Record<string, DocLayout>;
18
+ /** `sources/{type}.ts` loaders, usually the generated `sources`. Resolved by the caller. */
19
+ sources?: Record<string, SourceDef>;
20
+ render?: (doc: BackmatterDoc, body: ReactNode) => ReactNode;
21
+ };
22
+
23
+ export async function renderLayoutDoc(
24
+ doc: BackmatterDoc,
25
+ options: LayoutDocOptions,
26
+ data?: unknown,
27
+ ): Promise<ReactNode> {
28
+ const model = options.models.find((item) => item.type === doc.type);
29
+ const isPage = Boolean(model && modelFormat(model) === "page");
30
+ const rendered = isPage
31
+ ? await renderPage(doc, options.blocks, options.fallbacks, data)
32
+ : await renderDoc(doc, { components: options.components });
33
+ const layout = options.layouts?.[doc.type];
34
+ const laidOut = layout ? layout(doc, rendered, data) : rendered;
35
+ if (options.render) return options.render(doc, laidOut);
36
+ if (layout || isPage) return laidOut;
37
+ return createElement("article", null, laidOut);
38
+ }
package/src/loader.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  // packages/next/src/loader.ts
2
2
  // Bundler loader for `.backmatter/index.ts`, `.backmatter/blocks.ts`,
3
- // `.backmatter/mdx.ts`, `.backmatter/richtext.ts`, and `.backmatter/layouts.ts`.
3
+ // `.backmatter/mdx.ts`, `.backmatter/richtext.ts`, `.backmatter/layouts.ts`, and `.backmatter/sources.ts`.
4
4
  //
5
5
  // This is the Tailwind-JIT mechanism: instead of running our own watcher we
6
6
  // tell the bundler that these modules depend on the `models/`, `blocks/`,
7
- // `richtext/`, `mdx/`, and `layouts/` directories. webpack and Turbopack already watch the filesystem;
7
+ // `richtext/`, `mdx/`, `layouts/`, and `sources/` directories. webpack and Turbopack already watch the filesystem;
8
8
  // when a file is added, removed or renamed there they invalidate the module,
9
9
  // re-run this loader, and we regenerate the barrel from a fresh `readdir`.
10
10
  import path from "node:path";
@@ -19,6 +19,7 @@ export type BackmatterLoaderOptions = {
19
19
  mdxDir: string;
20
20
  layoutsDir: string;
21
21
  objectsDir: string;
22
+ sourcesDir: string;
22
23
  sdk: string;
23
24
  };
24
25
 
@@ -40,6 +41,7 @@ export default function backmatterLoader(this: BackmatterLoaderContext, source:
40
41
  const mdxDir = path.resolve(root, options.mdxDir);
41
42
  const layoutsDir = path.resolve(root, options.layoutsDir);
42
43
  const objectsDir = path.resolve(root, options.objectsDir);
44
+ const sourcesDir = path.resolve(root, options.sourcesDir);
43
45
 
44
46
  this.addContextDependency(modelsDir);
45
47
  this.addContextDependency(blocksDir);
@@ -47,6 +49,7 @@ export default function backmatterLoader(this: BackmatterLoaderContext, source:
47
49
  this.addContextDependency(mdxDir);
48
50
  this.addContextDependency(layoutsDir);
49
51
  this.addContextDependency(objectsDir);
52
+ this.addContextDependency(sourcesDir);
50
53
  this.cacheable?.(true);
51
54
 
52
55
  const result = generate({
@@ -58,6 +61,7 @@ export default function backmatterLoader(this: BackmatterLoaderContext, source:
58
61
  mdxDir: options.mdxDir,
59
62
  layoutsDir: options.layoutsDir,
60
63
  objectsDir: options.objectsDir,
64
+ sourcesDir: options.sourcesDir,
61
65
  sdk: options.sdk,
62
66
  });
63
67
  if (result.configFile) this.addDependency(result.configFile);