@backmatter/core 0.0.18 → 0.0.20

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backmatter/core",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
4
4
  "description": "Experimental Backmatter SDK. APIs will break. Not ready for production.",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/src/block.ts CHANGED
@@ -39,9 +39,17 @@ export function defineBlock<const D extends BlockDef>(def: D): D {
39
39
  if (def.component === undefined) {
40
40
  throw new Error("block.component is required");
41
41
  }
42
+ if (RESERVED_BLOCK_PROP in def.fields) {
43
+ throw new Error(
44
+ `block.fields.${RESERVED_BLOCK_PROP} is reserved: it carries the external source record`,
45
+ );
46
+ }
42
47
  return def;
43
48
  }
44
49
 
50
+ /** Prop name the renderer uses for an external model's record. */
51
+ export const RESERVED_BLOCK_PROP = "data";
52
+
45
53
  type Simplify<T> = { -readonly [K in keyof T]: T[K] } & {};
46
54
 
47
55
  type ScalarOf<K extends FieldKind> = K extends "number"
@@ -58,8 +66,11 @@ type IsRequired<F> = F extends { required?: infer R }
58
66
  : false
59
67
  : false;
60
68
 
61
- /** TypeScript shape of a block's props, derived from `field.*` calls. */
62
- export type InferBlockProps<Fields extends Record<string, FieldDef>> = Simplify<
69
+ /**
70
+ * TypeScript shape of a block's props, derived from `field.*` calls. Pass the
71
+ * record type as `Data` for blocks on an `external` model to type `props.data`.
72
+ */
73
+ export type InferBlockProps<Fields extends Record<string, FieldDef>, Data = undefined> = Simplify<
63
74
  {
64
75
  [N in keyof Fields as IsRequired<Fields[N]> extends true ? N : never]: ScalarOf<
65
76
  Fields[N]["kind"]
@@ -68,7 +79,7 @@ export type InferBlockProps<Fields extends Record<string, FieldDef>> = Simplify<
68
79
  [N in keyof Fields as IsRequired<Fields[N]> extends true ? never : N]?: ScalarOf<
69
80
  Fields[N]["kind"]
70
81
  >;
71
- }
82
+ } & ([Data] extends [undefined] ? {} : { data: Data })
72
83
  >;
73
84
 
74
85
  export function serializeBlock(block: BlockDef): BlockSnapshot {
package/src/config.ts CHANGED
@@ -16,6 +16,8 @@ export type BackmatterDirs = {
16
16
  layouts?: string;
17
17
  /** Folder with one `defineObject` default export per file. Default: "objects". */
18
18
  objects?: string;
19
+ /** Folder with one `defineSource` default export per external model type. Default: "sources". */
20
+ sources?: string;
19
21
  };
20
22
 
21
23
  export type BackmatterConfig = {
@@ -57,9 +59,12 @@ export const DEFAULT_DIRS: Required<BackmatterDirs> = {
57
59
  mdx: "mdx",
58
60
  layouts: "layouts",
59
61
  objects: "objects",
62
+ sources: "sources",
60
63
  };
61
64
  export const DEFAULT_LOCALE = "en";
62
65
  export const CLOUD_API_URL = "https://api.backmatter.app";
66
+ export const CLOUD_DASHBOARD_URL = "https://dash.backmatter.app";
67
+ export const LOCAL_DASHBOARD_URL = "http://localhost:5173";
63
68
 
64
69
  function withHttps(url: string): string {
65
70
  if (url.startsWith("http://") || url.startsWith("https://")) return url;
@@ -85,6 +90,29 @@ export function isDeployedHost(env: Record<string, string | undefined>): boolean
85
90
  );
86
91
  }
87
92
 
93
+ /**
94
+ * Dashboard origin the dev bar frames.
95
+ * Explicit URL, else `BACKMATTER_DASHBOARD_URL`, else the cloud dashboard on a
96
+ * deployed host, else the local Vite app. A loopback URL in `.env` is ignored
97
+ * during a local `next build`, so the built site does not keep pointing at the laptop.
98
+ */
99
+ export function resolveDashboardUrl(
100
+ explicit?: string,
101
+ env: Record<string, string | undefined> = envOf(),
102
+ ): string {
103
+ const fromProp = explicit?.trim();
104
+ const fromEnv = fromProp ? undefined : env.BACKMATTER_DASHBOARD_URL?.trim();
105
+ const candidate = fromProp || fromEnv || "";
106
+ const ignoreDevLoopback =
107
+ !fromProp &&
108
+ Boolean(candidate && isLoopbackApiUrl(candidate)) &&
109
+ env.NEXT_PHASE === "phase-production-build" &&
110
+ !onPlatformDeploy(env);
111
+ if (candidate && !ignoreDevLoopback) return stripTrailingSlash(candidate);
112
+ if (isDeployedHost(env)) return CLOUD_DASHBOARD_URL;
113
+ return LOCAL_DASHBOARD_URL;
114
+ }
115
+
88
116
  /** DevBar /me origin: explicit, else env, else cloud API on a deployed host. */
89
117
  export function resolveDevBarApiUrl(
90
118
  explicit: string | undefined,
@@ -190,6 +218,7 @@ export function resolveConfig(
190
218
  mdx: config.dirs?.mdx ?? DEFAULT_DIRS.mdx,
191
219
  layouts: config.dirs?.layouts ?? DEFAULT_DIRS.layouts,
192
220
  objects: config.dirs?.objects ?? DEFAULT_DIRS.objects,
221
+ sources: config.dirs?.sources ?? DEFAULT_DIRS.sources,
193
222
  },
194
223
  };
195
224
  }
@@ -40,6 +40,22 @@ export function listCacheTags(type?: string): string[] {
40
40
  return type ? [BACKMATTER_CACHE_TAG, `backmatter:${type}`] : [BACKMATTER_CACHE_TAG];
41
41
  }
42
42
 
43
+ /**
44
+ * Cache tags a commit should expire. A normal commit expires the touched
45
+ * documents and their type lists. A full rebuild expires the global tag,
46
+ * which every cached entry carries.
47
+ */
48
+ export function revalidateTagsForCommit(event: Pick<ContentCommittedEvent, "docs" | "full">): string[] {
49
+ if (event.full) return [BACKMATTER_CACHE_TAG];
50
+ const tags = new Set<string>();
51
+ for (const doc of event.docs ?? []) {
52
+ for (const tag of contentCacheTags(doc.locale, doc.type, doc.slug)) {
53
+ if (tag !== BACKMATTER_CACHE_TAG) tags.add(tag);
54
+ }
55
+ }
56
+ return [...tags];
57
+ }
58
+
43
59
  /** Site paths the host should `revalidatePath` for these docs. */
44
60
  export function pathsForCommittedDocs(
45
61
  models: readonly RouteModel[],
package/src/enter.ts ADDED
@@ -0,0 +1,68 @@
1
+ // packages/core/src/enter.ts
2
+ // The dashboard is the identity provider. A site that needs a session sends
3
+ // the browser top-level to `{dashboard}/enter`, which mints a ticket from the
4
+ // first-party cookie and returns to `{site}/api/backmatter/preview?ticket=`.
5
+
6
+ export const ENTER_PATH = "/enter";
7
+ export const PREVIEW_ENTRY_PATH = "/api/backmatter/preview";
8
+
9
+ export type EnterRequest = {
10
+ workspaceId: string;
11
+ /** Absolute URL on the site to land on after the session is set. */
12
+ returnUrl: string;
13
+ };
14
+
15
+ export function studioEnterUrl(dashboardUrl: string, req: EnterRequest): string {
16
+ const url = new URL(ENTER_PATH, dashboardUrl.replace(/\/+$/, "") + "/");
17
+ url.searchParams.set("workspace", req.workspaceId);
18
+ url.searchParams.set("return", req.returnUrl);
19
+ return url.toString();
20
+ }
21
+
22
+ export function parseEnterSearch(search: string): EnterRequest | null {
23
+ const params = new URLSearchParams(search);
24
+ const workspaceId = params.get("workspace")?.trim() ?? "";
25
+ const returnUrl = params.get("return")?.trim() ?? "";
26
+ if (!workspaceId || !returnUrl) return null;
27
+ return { workspaceId, returnUrl };
28
+ }
29
+
30
+ export type EnterTarget = {
31
+ /** Site origin the ticket is for. Must be one of the workspace's `siteUrls`. */
32
+ origin: string;
33
+ /** Same-origin path, with search, the ticket redirects to. */
34
+ redirect: string;
35
+ };
36
+
37
+ /**
38
+ * Resolve where a return URL points, allowing only origins the workspace has
39
+ * declared. Returns null for a foreign origin, a bad URL, or a non-http scheme.
40
+ */
41
+ export function resolveEnterTarget(returnUrl: string, siteUrls: readonly string[]): EnterTarget | null {
42
+ let url: URL;
43
+ try {
44
+ url = new URL(returnUrl);
45
+ } catch {
46
+ return null;
47
+ }
48
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
49
+ const allowed = new Set(
50
+ siteUrls
51
+ .map((raw) => {
52
+ try {
53
+ return new URL(raw).origin;
54
+ } catch {
55
+ return null;
56
+ }
57
+ })
58
+ .filter((o): o is string => Boolean(o)),
59
+ );
60
+ if (!allowed.has(url.origin)) return null;
61
+ const redirect = `${url.pathname}${url.search}` || "/";
62
+ return { origin: url.origin, redirect };
63
+ }
64
+
65
+ /** The URL the dashboard sends the browser to once it holds a ticket. */
66
+ export function previewEntryUrl(origin: string, ticket: string): string {
67
+ return `${origin}${PREVIEW_ENTRY_PATH}?ticket=${encodeURIComponent(ticket)}`;
68
+ }
package/src/fields.ts CHANGED
@@ -28,6 +28,12 @@ export type FieldDef<
28
28
  object?: string;
29
29
  /** Nested fields for object/array (inline or copied from defineObject). */
30
30
  fields?: Record<string, FieldDef>;
31
+ /**
32
+ * Name of the Studio input that edits this field, e.g. `"color"`. The site's
33
+ * `defineStudio({ fields })` maps names to components; unknown names fall
34
+ * back to the default input for the kind.
35
+ */
36
+ input?: string;
31
37
  };
32
38
 
33
39
  export type FieldSnapshot = {
@@ -36,20 +42,22 @@ export type FieldSnapshot = {
36
42
  relationTo?: string;
37
43
  object?: string;
38
44
  fields?: Record<string, FieldSnapshot>;
45
+ input?: string;
39
46
  };
40
47
 
41
48
  type ObjectLike = { name: string; fields: Record<string, FieldDef> };
42
49
 
43
- type Opts<R extends boolean> = { required?: R };
50
+ type Opts<R extends boolean> = { required?: R; input?: string };
44
51
 
45
52
  function base<K extends FieldKind, R extends boolean, To extends string>(
46
53
  kind: K,
47
- opts?: { required?: R; relationTo?: To },
54
+ opts?: { required?: R; relationTo?: To; input?: string },
48
55
  ): FieldDef<K, R, To> {
49
56
  return {
50
57
  kind,
51
58
  ...(opts?.required ? { required: opts.required } : {}),
52
59
  ...(opts?.relationTo ? { relationTo: opts.relationTo } : {}),
60
+ ...(opts?.input ? { input: opts.input } : {}),
53
61
  } as FieldDef<K, R, To>;
54
62
  }
55
63
 
@@ -59,10 +67,11 @@ function composite<K extends "object" | "array", R extends boolean>(
59
67
  opts?: Opts<R>,
60
68
  ): FieldDef<K, R> {
61
69
  const required = opts?.required ? { required: opts.required as R } : {};
70
+ const input = opts?.input ? { input: opts.input } : {};
62
71
  if (typeof def === "string") {
63
- return { kind, object: def, ...required };
72
+ return { kind, object: def, ...required, ...input };
64
73
  }
65
- return { kind, object: def.name, fields: def.fields, ...required };
74
+ return { kind, object: def.name, fields: def.fields, ...required, ...input };
66
75
  }
67
76
 
68
77
  export const field = {
@@ -86,6 +95,7 @@ export function serializeField(def: FieldDef): FieldSnapshot {
86
95
  ...(def.required ? { required: true } : {}),
87
96
  ...(def.relationTo ? { relationTo: def.relationTo } : {}),
88
97
  ...(def.object ? { object: def.object } : {}),
98
+ ...(def.input ? { input: def.input } : {}),
89
99
  };
90
100
  if (def.fields) {
91
101
  const fields: Record<string, FieldSnapshot> = {};
@@ -125,6 +135,9 @@ export function parseFieldSnapshot(input: unknown): FieldSnapshot {
125
135
  if (typeof value.object === "string" && value.object.length > 0) {
126
136
  snap.object = value.object;
127
137
  }
138
+ if (typeof value.input === "string" && value.input.length > 0) {
139
+ snap.input = value.input;
140
+ }
128
141
  if (value.fields && typeof value.fields === "object" && !Array.isArray(value.fields)) {
129
142
  const fields: Record<string, FieldSnapshot> = {};
130
143
  for (const [name, nested] of Object.entries(value.fields as Record<string, unknown>)) {
package/src/index.ts CHANGED
@@ -24,7 +24,9 @@ export {
24
24
  defaultModelPath,
25
25
  DEFAULT_MODEL_PATH,
26
26
  DEFAULT_PAGE_PATH,
27
+ DEFAULT_TEMPLATE_SLUG,
27
28
  IMPLICIT_SINGLETON_SLUG,
29
+ isTemplateSlug,
28
30
  serializeModel,
29
31
  serializeSchema,
30
32
  parseSchemaSnapshot,
@@ -40,8 +42,12 @@ export {
40
42
  defineBlock,
41
43
  serializeBlock,
42
44
  parseBlockSnapshot,
45
+ RESERVED_BLOCK_PROP,
43
46
  } from "./block";
44
47
 
48
+ export type { SourceDef, SourceRef } from "./source";
49
+ export { defineSource } from "./source";
50
+
45
51
  export type { DocFormat } from "./doc-format";
46
52
  export { modelFormat, parseDoc, serializeDoc } from "./doc-format";
47
53
 
@@ -52,9 +58,12 @@ export {
52
58
  detectSiteUrl,
53
59
  isDeployedHost,
54
60
  resolveDevBarApiUrl,
61
+ resolveDashboardUrl,
55
62
  DEFAULT_DIRS,
56
63
  DEFAULT_LOCALE,
57
64
  CLOUD_API_URL,
65
+ CLOUD_DASHBOARD_URL,
66
+ LOCAL_DASHBOARD_URL,
58
67
  } from "./config";
59
68
 
60
69
  export type { RouteMatch, RouteModel, MatchRouteOptions } from "./routes";
@@ -118,21 +127,30 @@ export {
118
127
  export type { BackmatterDoc, BackmatterDocSummary } from "./doc";
119
128
  export { createQuery } from "./query";
120
129
  export type { Query, ModelQuery, TypedDoc, Where, Include, DocLoader } from "./query";
121
- export type { PreviewCheckout, PreviewTicket } from "./preview-session";
130
+ export type { PreviewCheckout, PreviewSessionClaims, PreviewTicket } from "./preview-session";
122
131
  export {
123
132
  PREVIEW_COOKIE,
133
+ PREVIEW_SESSION_COOKIE,
134
+ PREVIEW_SESSION_QUERY,
135
+ PREVIEW_SESSION_STORAGE_KEY,
124
136
  PREVIEW_ENABLE_PATH,
125
137
  PREVIEW_QUERY,
126
138
  PREVIEW_CHECKOUT_QUERY,
127
139
  previewLocationWithCheckout,
128
140
  isSafeRedirectPath,
141
+ mintPreviewSession,
129
142
  mintPreviewTicket,
143
+ peekPreviewSessionWorkspace,
130
144
  previewMiddlewareDecision,
131
145
  readPreviewCookie,
146
+ readPreviewSession,
132
147
  readPreviewTicket,
148
+ scopePreviewAccess,
149
+ takePreviewSessionFromSearch,
133
150
  writePreviewCookie,
134
151
  PREVIEW_TICKET_TTL_MS,
135
152
  PREVIEW_COOKIE_TTL_MS,
153
+ PREVIEW_SESSION_TTL_MS,
136
154
  } from "./preview-session";
137
155
  export { signPayload, verifyPayload } from "./signed-token";
138
156
  export { getDocFromApi, listDocsFromApi } from "./api-loader";
@@ -147,5 +165,17 @@ export {
147
165
  contentCacheTags,
148
166
  listCacheTags,
149
167
  pathsForCommittedDocs,
168
+ revalidateTagsForCommit,
150
169
  revalidateUrl,
151
170
  } from "./content-committed";
171
+
172
+ export {
173
+ ENTER_PATH,
174
+ PREVIEW_ENTRY_PATH,
175
+ parseEnterSearch,
176
+ previewEntryUrl,
177
+ resolveEnterTarget,
178
+ studioEnterUrl,
179
+ type EnterRequest,
180
+ type EnterTarget,
181
+ } from "./enter";
package/src/model.ts CHANGED
@@ -33,12 +33,51 @@ export type ModelDef = {
33
33
  * in a schema may set this.
34
34
  */
35
35
  indexSlug?: string;
36
+ /**
37
+ * Slugs belong to an outside system (a catalog). The host provides
38
+ * `sources/{type}.ts`; a record that does not exist there is a 404. A
39
+ * document at the `template` slug renders every record without its own file.
40
+ */
41
+ external?: boolean;
42
+ /** Reserved slug of the template document. Default `_template` when `external`. */
43
+ template?: string;
36
44
  fields: Record<string, FieldDef>;
37
45
  };
38
46
 
39
47
  /** What `defineModel` accepts: `path` falls back to the folder convention. */
40
48
  export type ModelInput = Omit<ModelDef, "path"> & { path?: string };
41
49
 
50
+ export const DEFAULT_TEMPLATE_SLUG = "_template";
51
+
52
+ /** True when `slug` is the template document of an external model. */
53
+ export function isTemplateSlug(
54
+ model: Pick<ModelDef, "type" | "external" | "template"> | null | undefined,
55
+ slug: string,
56
+ ): boolean {
57
+ if (!model?.external) return false;
58
+ return slug === (model.template ?? DEFAULT_TEMPLATE_SLUG);
59
+ }
60
+
61
+ /** Validate `external` / `template` and return the template slug to store, if any. */
62
+ export function resolveTemplateSlug(
63
+ def: { external?: boolean; template?: string; singleton?: boolean; indexSlug?: string },
64
+ label = "model",
65
+ ): string | undefined {
66
+ if (def.template !== undefined && !def.external) {
67
+ throw new Error(`${label}.template requires external: true`);
68
+ }
69
+ if (!def.external) return undefined;
70
+ if (def.singleton) throw new Error(`${label}.external cannot be combined with singleton`);
71
+ if (def.indexSlug !== undefined) {
72
+ throw new Error(`${label}.external cannot be combined with indexSlug`);
73
+ }
74
+ if (def.template === undefined) return DEFAULT_TEMPLATE_SLUG;
75
+ if (typeof def.template !== "string" || !def.template.trim()) {
76
+ throw new Error(`${label}.template must be a non-empty string`);
77
+ }
78
+ return def.template.trim();
79
+ }
80
+
42
81
  /** Default repo path when a model does not declare one. */
43
82
  export const DEFAULT_MODEL_PATH = "content/{locale}/{type}/{slug}.mdx";
44
83
  export const DEFAULT_PAGE_PATH = "content/{locale}/{type}/{slug}.json";
@@ -55,6 +94,8 @@ export type ModelSnapshot = {
55
94
  singleton?: boolean;
56
95
  format?: DocFormat;
57
96
  indexSlug?: string;
97
+ external?: boolean;
98
+ template?: string;
58
99
  fields: Record<string, FieldSnapshot>;
59
100
  };
60
101
 
@@ -91,12 +132,17 @@ export type SchemaSnapshot = {
91
132
  };
92
133
 
93
134
  type WithPath<D extends ModelInput> = D extends { path: string } ? D : D & { path: string };
135
+ type WithTemplate<D extends ModelInput> = D extends { external: true }
136
+ ? D extends { template: string }
137
+ ? D
138
+ : D & { template: string }
139
+ : D;
94
140
 
95
141
  /**
96
142
  * Validates the definition at runtime and preserves its literal types.
97
143
  * `path` defaults to `content/{locale}/{type}/{slug}.mdx`.
98
144
  */
99
- export function defineModel<const D extends ModelInput>(def: D): WithPath<D> {
145
+ export function defineModel<const D extends ModelInput>(def: D): WithTemplate<WithPath<D>> {
100
146
  if (!def.type || typeof def.type !== "string") {
101
147
  throw new Error("model.type is required");
102
148
  }
@@ -123,7 +169,11 @@ export function defineModel<const D extends ModelInput>(def: D): WithPath<D> {
123
169
  if (!def.fields || typeof def.fields !== "object") {
124
170
  throw new Error("model.fields is required");
125
171
  }
126
- const withPath = (def.path === undefined ? { ...def, path } : { ...def }) as WithPath<D>;
172
+ const template = resolveTemplateSlug(def);
173
+ const withPath = (def.path === undefined ? { ...def, path } : { ...def }) as WithTemplate<
174
+ WithPath<D>
175
+ >;
176
+ if (template !== undefined) (withPath as { template?: string }).template = template;
127
177
  if (singleton && def.route === undefined) {
128
178
  return { ...withPath, route: false };
129
179
  }
@@ -173,6 +223,10 @@ export function serializeModel(model: ModelDef): ModelSnapshot {
173
223
  if (model.singleton) snapshot.singleton = true;
174
224
  if (model.format === "page") snapshot.format = "page";
175
225
  if (model.indexSlug !== undefined) snapshot.indexSlug = model.indexSlug.trim();
226
+ if (model.external) {
227
+ snapshot.external = true;
228
+ snapshot.template = model.template ?? DEFAULT_TEMPLATE_SLUG;
229
+ }
176
230
  return snapshot;
177
231
  }
178
232
 
@@ -264,6 +318,22 @@ export function parseSchemaSnapshot(input: unknown): SchemaSnapshot {
264
318
  }
265
319
  snapshot.indexSlug = m.indexSlug.trim();
266
320
  }
321
+ if (m.external !== undefined && typeof m.external !== "boolean") {
322
+ throw new Error(`schema.models[${i}].external must be a boolean`);
323
+ }
324
+ const template = resolveTemplateSlug(
325
+ {
326
+ external: m.external === true,
327
+ template: m.template as string | undefined,
328
+ singleton,
329
+ indexSlug: snapshot.indexSlug,
330
+ },
331
+ `schema.models[${i}]`,
332
+ );
333
+ if (template !== undefined) {
334
+ snapshot.external = true;
335
+ snapshot.template = template;
336
+ }
267
337
  return snapshot;
268
338
  });
269
339
 
package/src/node/cli.ts CHANGED
@@ -30,7 +30,7 @@ export type CliArgs = {
30
30
 
31
31
  const USAGE = `Usage:
32
32
  backmatter init scaffold backmatter.config.ts, the catch-all route and .backmatter/
33
- backmatter generate [--root <dir>] write .backmatter/index.ts (typed cms), blocks.ts, mdx.ts and layouts.ts
33
+ backmatter generate [--root <dir>] write .backmatter/index.ts (typed cms), blocks.ts, mdx.ts, layouts.ts and sources.ts
34
34
  backmatter validate [--root <dir>] [--models <file>] [--json]
35
35
  backmatter schema [--root <dir>] [--models <file>] [--out <dir>]
36
36
 
@@ -239,6 +239,7 @@ export async function runGenerate(args: CliArgs): Promise<GenerateResult> {
239
239
  mdxDir: config.dirs.mdx,
240
240
  layoutsDir: config.dirs.layouts,
241
241
  objectsDir: config.dirs.objects,
242
+ sourcesDir: config.dirs.sources,
242
243
  });
243
244
  }
244
245
 
@@ -272,9 +273,10 @@ import { richtextComponents } from "${REGISTRY_SPECIFIER}/richtext";
272
273
  import { blockLoaders } from "${REGISTRY_SPECIFIER}/blocks";
273
274
  import { blockFallbacks } from "${REGISTRY_SPECIFIER}/block-fallbacks";
274
275
  import { layouts } from "${REGISTRY_SPECIFIER}/layouts";
276
+ import { sources } from "${REGISTRY_SPECIFIER}/sources";
275
277
 
276
278
  const components = { ...richtextComponents, ...mdxComponents };
277
- const route = createCatchAll(cms, { components, blocks: blockLoaders, fallbacks: blockFallbacks, layouts });
279
+ const route = createCatchAll(cms, { components, blocks: blockLoaders, fallbacks: blockFallbacks, layouts, sources });
278
280
 
279
281
  export const generateStaticParams = route.generateStaticParams;
280
282
 
@@ -348,6 +350,53 @@ export const config = {
348
350
  `;
349
351
  }
350
352
 
353
+ function renderCmsProxyRoute(): string {
354
+ return `// api/backmatter/cms/[...path]/route.ts — same-origin proxy for the preview session.
355
+ import { createCmsProxy } from "@backmatter/next/cms-proxy";
356
+ import { cms } from "${REGISTRY_SPECIFIER}";
357
+
358
+ export const { GET, POST, PUT, DELETE } = createCmsProxy(cms);
359
+ `;
360
+ }
361
+
362
+ const STUDIO_DIR = path.join("(backmatter)", "backmatter");
363
+
364
+ function renderStudioLayout(): string {
365
+ return `// app/(backmatter)/backmatter/layout.tsx — own root layout so the site's
366
+ // header, footer, fonts and global CSS do not wrap the Studio.
367
+ export { StudioLayout as default } from "@backmatter/next/studio";
368
+ `;
369
+ }
370
+
371
+ function renderStudioPage(studioImport: string): string {
372
+ return `// app/(backmatter)/backmatter/[[...path]]/page.tsx — the Backmatter Studio,
373
+ // mounted on this site. Editors sign in at the dashboard; the Studio talks to
374
+ // /api/backmatter/cms on this origin with the site session cookie.
375
+ import { createStudioPage } from "@backmatter/next/studio";
376
+ import { cms } from "${REGISTRY_SPECIFIER}";
377
+ import studio from "${studioImport}";
378
+
379
+ export default createStudioPage(cms, { studio });
380
+ `;
381
+ }
382
+
383
+ function renderStudioDefinition(): string {
384
+ return `// backmatter.studio.tsx — how the Studio looks and which inputs it uses on
385
+ // this site. A "use client" module: field inputs and block previews are
386
+ // ordinary React components bundled by the site.
387
+ "use client";
388
+
389
+ import { defineStudio } from "@backmatter/studio/define";
390
+
391
+ export default defineStudio({
392
+ // theme: { accent: "#1d4ed8" },
393
+ // fields: { color: ColorInput }, // field.text({ input: "color" })
394
+ // blocks: { Hero: { preview: HeroPreview } },
395
+ menu: [{ label: "Open site", href: "/" }],
396
+ });
397
+ `;
398
+ }
399
+
351
400
  function renderRevalidateRoute(): string {
352
401
  return `// api/backmatter/revalidate/route.ts — content.committed from the BACKMATTER server.
353
402
  import { createRevalidateRoute } from "@backmatter/next/revalidate";
@@ -402,6 +451,16 @@ export async function runInit(args: CliArgs): Promise<string[]> {
402
451
  notes.push(`kept existing ${path.join(appDir, "api/backmatter/revalidate/route.ts")}`);
403
452
  }
404
453
 
454
+ const cmsDir = path.join(root, appDir, "api", "backmatter", "cms", "[...path]");
455
+ const cmsFile = path.join(cmsDir, "route.ts");
456
+ if (!existsSync(cmsFile)) {
457
+ await mkdir(cmsDir, { recursive: true });
458
+ await writeFile(cmsFile, renderCmsProxyRoute());
459
+ notes.push(`wrote ${path.join(appDir, "api/backmatter/cms/[...path]/route.ts")}`);
460
+ } else {
461
+ notes.push(`kept existing ${path.join(appDir, "api/backmatter/cms/[...path]/route.ts")}`);
462
+ }
463
+
405
464
  const previewDir = path.join(root, appDir, "api", "backmatter", "preview");
406
465
  const previewFile = path.join(previewDir, "route.ts");
407
466
  if (!existsSync(previewFile)) {
@@ -412,6 +471,37 @@ export async function runInit(args: CliArgs): Promise<string[]> {
412
471
  notes.push(`kept existing ${path.join(appDir, "api/backmatter/preview/route.ts")}`);
413
472
  }
414
473
 
474
+ const studioDefinition = path.join(root, "backmatter.studio.tsx");
475
+ if (!existsSync(studioDefinition)) {
476
+ await writeFile(studioDefinition, renderStudioDefinition());
477
+ notes.push("wrote backmatter.studio.tsx");
478
+ } else {
479
+ notes.push("kept existing backmatter.studio.tsx");
480
+ }
481
+
482
+ const studioDir = path.join(root, appDir, STUDIO_DIR);
483
+ const studioLayout = path.join(studioDir, "layout.tsx");
484
+ if (!existsSync(studioLayout)) {
485
+ await mkdir(studioDir, { recursive: true });
486
+ await writeFile(studioLayout, renderStudioLayout());
487
+ notes.push(`wrote ${path.join(appDir, STUDIO_DIR, "layout.tsx")}`);
488
+ } else {
489
+ notes.push(`kept existing ${path.join(appDir, STUDIO_DIR, "layout.tsx")}`);
490
+ }
491
+ const studioPageDir = path.join(studioDir, "[[...path]]");
492
+ const studioPage = path.join(studioPageDir, "page.tsx");
493
+ if (!existsSync(studioPage)) {
494
+ await mkdir(studioPageDir, { recursive: true });
495
+ const studioImport = path
496
+ .relative(studioPageDir, path.join(root, "backmatter.studio"))
497
+ .split(path.sep)
498
+ .join("/");
499
+ await writeFile(studioPage, renderStudioPage(studioImport));
500
+ notes.push(`wrote ${path.join(appDir, STUDIO_DIR, "[[...path]]", "page.tsx")}`);
501
+ } else {
502
+ notes.push(`kept existing ${path.join(appDir, STUDIO_DIR, "[[...path]]", "page.tsx")}`);
503
+ }
504
+
415
505
  const proxyFile = path.join(root, "proxy.ts");
416
506
  const srcProxy = path.join(root, "src", "proxy.ts");
417
507
  const middlewareFile = path.join(root, "middleware.ts");
@@ -450,7 +540,7 @@ export async function runInit(args: CliArgs): Promise<string[]> {
450
540
  notes.push("no @backmatter/next dependency: skipped the catch-all route");
451
541
  }
452
542
 
453
- for (const dir of ["models", "content", "blocks", "richtext", "mdx", "layouts"]) {
543
+ for (const dir of ["models", "content", "blocks", "richtext", "mdx", "layouts", "sources"]) {
454
544
  await mkdir(path.join(root, dir), { recursive: true });
455
545
  }
456
546
 
@@ -101,6 +101,11 @@ export function listLayoutFiles(root: string, layoutsDir = "layouts"): ModelFile
101
101
  export function listObjectFiles(root: string, objectsDir = "objects"): ModelFile[] {
102
102
  return listModelFiles(root, objectsDir);
103
103
  }
104
+
105
+ /** `sources/{type}.ts`: one `defineSource` default export per external model type. */
106
+ export function listSourceDefFiles(root: string, sourcesDir = "sources"): ModelFile[] {
107
+ return listModelFiles(root, sourcesDir);
108
+ }
104
109
  export function listBlockFiles(
105
110
  root: string,
106
111
  blocksDir = "blocks",