@backmatter/core 0.0.18 → 0.0.19

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.19",
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,6 +59,7 @@ 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";
@@ -190,6 +193,7 @@ export function resolveConfig(
190
193
  mdx: config.dirs?.mdx ?? DEFAULT_DIRS.mdx,
191
194
  layouts: config.dirs?.layouts ?? DEFAULT_DIRS.layouts,
192
195
  objects: config.dirs?.objects ?? DEFAULT_DIRS.objects,
196
+ sources: config.dirs?.sources ?? DEFAULT_DIRS.sources,
193
197
  },
194
198
  };
195
199
  }
@@ -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/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
 
@@ -118,21 +124,30 @@ export {
118
124
  export type { BackmatterDoc, BackmatterDocSummary } from "./doc";
119
125
  export { createQuery } from "./query";
120
126
  export type { Query, ModelQuery, TypedDoc, Where, Include, DocLoader } from "./query";
121
- export type { PreviewCheckout, PreviewTicket } from "./preview-session";
127
+ export type { PreviewCheckout, PreviewSessionClaims, PreviewTicket } from "./preview-session";
122
128
  export {
123
129
  PREVIEW_COOKIE,
130
+ PREVIEW_SESSION_COOKIE,
131
+ PREVIEW_SESSION_QUERY,
132
+ PREVIEW_SESSION_STORAGE_KEY,
124
133
  PREVIEW_ENABLE_PATH,
125
134
  PREVIEW_QUERY,
126
135
  PREVIEW_CHECKOUT_QUERY,
127
136
  previewLocationWithCheckout,
128
137
  isSafeRedirectPath,
138
+ mintPreviewSession,
129
139
  mintPreviewTicket,
140
+ peekPreviewSessionWorkspace,
130
141
  previewMiddlewareDecision,
131
142
  readPreviewCookie,
143
+ readPreviewSession,
132
144
  readPreviewTicket,
145
+ scopePreviewAccess,
146
+ takePreviewSessionFromSearch,
133
147
  writePreviewCookie,
134
148
  PREVIEW_TICKET_TTL_MS,
135
149
  PREVIEW_COOKIE_TTL_MS,
150
+ PREVIEW_SESSION_TTL_MS,
136
151
  } from "./preview-session";
137
152
  export { signPayload, verifyPayload } from "./signed-token";
138
153
  export { getDocFromApi, listDocsFromApi } from "./api-loader";
@@ -147,5 +162,6 @@ export {
147
162
  contentCacheTags,
148
163
  listCacheTags,
149
164
  pathsForCommittedDocs,
165
+ revalidateTagsForCommit,
150
166
  revalidateUrl,
151
167
  } from "./content-committed";
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,15 @@ 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
+
351
362
  function renderRevalidateRoute(): string {
352
363
  return `// api/backmatter/revalidate/route.ts — content.committed from the BACKMATTER server.
353
364
  import { createRevalidateRoute } from "@backmatter/next/revalidate";
@@ -402,6 +413,16 @@ export async function runInit(args: CliArgs): Promise<string[]> {
402
413
  notes.push(`kept existing ${path.join(appDir, "api/backmatter/revalidate/route.ts")}`);
403
414
  }
404
415
 
416
+ const cmsDir = path.join(root, appDir, "api", "backmatter", "cms", "[...path]");
417
+ const cmsFile = path.join(cmsDir, "route.ts");
418
+ if (!existsSync(cmsFile)) {
419
+ await mkdir(cmsDir, { recursive: true });
420
+ await writeFile(cmsFile, renderCmsProxyRoute());
421
+ notes.push(`wrote ${path.join(appDir, "api/backmatter/cms/[...path]/route.ts")}`);
422
+ } else {
423
+ notes.push(`kept existing ${path.join(appDir, "api/backmatter/cms/[...path]/route.ts")}`);
424
+ }
425
+
405
426
  const previewDir = path.join(root, appDir, "api", "backmatter", "preview");
406
427
  const previewFile = path.join(previewDir, "route.ts");
407
428
  if (!existsSync(previewFile)) {
@@ -450,7 +471,7 @@ export async function runInit(args: CliArgs): Promise<string[]> {
450
471
  notes.push("no @backmatter/next dependency: skipped the catch-all route");
451
472
  }
452
473
 
453
- for (const dir of ["models", "content", "blocks", "richtext", "mdx", "layouts"]) {
474
+ for (const dir of ["models", "content", "blocks", "richtext", "mdx", "layouts", "sources"]) {
454
475
  await mkdir(path.join(root, dir), { recursive: true });
455
476
  }
456
477
 
@@ -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",
@@ -12,6 +12,7 @@ import {
12
12
  listMdxFiles,
13
13
  listModelFiles,
14
14
  listObjectFiles,
15
+ listSourceDefFiles,
15
16
  type BlockFile,
16
17
  type ModelFile,
17
18
  } from "./discover";
@@ -35,6 +36,7 @@ export type GenerateOptions = {
35
36
  mdxDir?: string;
36
37
  layoutsDir?: string;
37
38
  objectsDir?: string;
39
+ sourcesDir?: string;
38
40
  /** Module that exports `createBackmatter`. Auto-detected from package.json when omitted. */
39
41
  sdk?: string;
40
42
  /** Write to disk. Default: true. `false` only renders (used by the bundler loader). */
@@ -64,6 +66,7 @@ export type GenerateResult = {
64
66
  mdx: BlockFile[];
65
67
  layouts: ModelFile[];
66
68
  objects: ModelFile[];
69
+ sources: ModelFile[];
67
70
  skippedBlocks: string[];
68
71
  skippedMdx: string[];
69
72
  /** Absolute path of `backmatter.config.*` when present. */
@@ -102,20 +105,32 @@ function renderCachedCmsClient(): string[] {
102
105
  return [
103
106
  "const raw = createBackmatter({ ...config, models, sync: false });",
104
107
  "",
105
- "async function getDoc(locale: string, type: string, slug: string) {",
108
+ "async function cachedGetDoc(locale: string, type: string, slug: string) {",
106
109
  ' "use cache";',
107
110
  ' cacheLife("hours");',
108
111
  " cacheTag(...contentCacheTags(locale, type, slug));",
109
112
  " return raw.getDoc(locale, type, slug);",
110
113
  "}",
111
114
  "",
112
- "async function listDocs(filter?: { type?: string; locale?: string }) {",
115
+ "async function cachedListDocs(filter?: { type?: string; locale?: string }) {",
113
116
  ' "use cache";',
114
117
  ' cacheLife("hours");',
115
118
  " cacheTag(...listCacheTags(filter?.type));",
116
119
  " return raw.listDocs(filter);",
117
120
  "}",
118
121
  "",
122
+ "async function getDoc(locale: string, type: string, slug: string) {",
123
+ " const preview = currentPreviewContent();",
124
+ " if (preview) return preview.getDoc(locale, type, slug);",
125
+ " return cachedGetDoc(locale, type, slug);",
126
+ "}",
127
+ "",
128
+ "async function listDocs(filter?: { type?: string; locale?: string }) {",
129
+ " const preview = currentPreviewContent();",
130
+ " if (preview) return preview.listDocs(filter);",
131
+ " return cachedListDocs(filter);",
132
+ "}",
133
+ "",
119
134
  "/** Typed content client: cms.query.{type}, cms.getDoc, cms.listDocs. */",
120
135
  "export const cms = {",
121
136
  " ...raw,",
@@ -136,6 +151,7 @@ export function renderModelsBarrel(input: {
136
151
  const lines = [GENERATED_HEADER, `import { createBackmatter } from "${input.sdk}";`];
137
152
  if (nextHost) {
138
153
  lines.push('import { contentCacheTags, createQuery, listCacheTags } from "@backmatter/core";');
154
+ lines.push('import { currentPreviewContent } from "@backmatter/next/preview-content";');
139
155
  lines.push('import { cacheLife, cacheTag } from "next/cache";');
140
156
  }
141
157
  if (input.configFile) {
@@ -258,6 +274,49 @@ export function renderBlockFallbacksBarrel(input: {
258
274
  return `${lines.join("\n")}\n`;
259
275
  }
260
276
 
277
+ /** Server action the catch-all uses to paint editor keystrokes. Next hosts only. */
278
+ export function renderPreviewAction(sdk: string): string {
279
+ if (sdk !== "@backmatter/next") {
280
+ return `${GENERATED_HEADER}
281
+ /** Instant preview needs @backmatter/next. This host has no server action. */
282
+ export const renderPreview = null;
283
+ `;
284
+ }
285
+ return `"use server";
286
+ ${GENERATED_HEADER}
287
+ import { createRenderPreview } from "@backmatter/next/render-preview";
288
+ import { cms } from "./index";
289
+ import { blockLoaders } from "./blocks";
290
+ import { layouts } from "./layouts";
291
+ import { sources } from "./sources";
292
+ import { components as mdxComponents } from "./mdx";
293
+ import { richtextComponents } from "./richtext";
294
+
295
+ const components = { ...richtextComponents, ...mdxComponents };
296
+
297
+ export const renderPreview = createRenderPreview(cms, {
298
+ components,
299
+ blocks: blockLoaders,
300
+ layouts,
301
+ sources,
302
+ });
303
+ `;
304
+ }
305
+
306
+ export function renderSourcesBarrel(input: { outDir: string; sources: ModelFile[] }): string {
307
+ const lines = [GENERATED_HEADER];
308
+ for (const source of input.sources) {
309
+ lines.push(`import ${source.importName} from "${importSpecifier(input.outDir, source.file)}";`);
310
+ }
311
+ lines.push("");
312
+ lines.push("/** External record loaders: sources/{type}.ts resolves that model's route data. */");
313
+ const entries = input.sources.map((s) =>
314
+ s.importName === s.stem ? s.stem : `${JSON.stringify(s.stem)}: ${s.importName}`,
315
+ );
316
+ lines.push(`export const sources = ${entries.length ? `{ ${entries.join(", ")} }` : "{}"};`);
317
+ return `${lines.join("\n")}\n`;
318
+ }
319
+
261
320
  export function renderLayoutsBarrel(input: { outDir: string; layouts: ModelFile[] }): string {
262
321
  const lines = [GENERATED_HEADER];
263
322
  for (const layout of input.layouts) {
@@ -284,7 +343,9 @@ export const REGISTRY_FILES = [
284
343
  "mdx.ts",
285
344
  "richtext.ts",
286
345
  "layouts.ts",
346
+ "sources.ts",
287
347
  "sync.ts",
348
+ "preview-action.ts",
288
349
  ] as const;
289
350
 
290
351
  export function generate(options: GenerateOptions): GenerateResult {
@@ -306,6 +367,7 @@ export function generate(options: GenerateOptions): GenerateResult {
306
367
  );
307
368
  const layouts = listLayoutFiles(root, options.layoutsDir ?? DEFAULT_DIRS.layouts);
308
369
  const objects = listObjectFiles(root, options.objectsDir ?? DEFAULT_DIRS.objects);
370
+ const sources = listSourceDefFiles(root, options.sourcesDir ?? DEFAULT_DIRS.sources);
309
371
  const sdk = options.sdk ?? detectSdk(root);
310
372
  const configFile = findConfigFile(root);
311
373
  const write = options.write ?? true;
@@ -317,7 +379,9 @@ export function generate(options: GenerateOptions): GenerateResult {
317
379
  "mdx.ts": renderMdxBarrel({ outDir, mdx }),
318
380
  "richtext.ts": renderRichtextBarrel({ outDir, richtext }),
319
381
  "layouts.ts": renderLayoutsBarrel({ outDir, layouts }),
382
+ "sources.ts": renderSourcesBarrel({ outDir, sources }),
320
383
  "sync.ts": renderSyncBarrel({ outDir, models, blocks, richtext, objects, configFile, sdk }),
384
+ "preview-action.ts": renderPreviewAction(sdk),
321
385
  };
322
386
  const outputs: GeneratedFile[] = REGISTRY_FILES.map((name) => {
323
387
  const file = path.join(outDir, name);
@@ -339,6 +403,7 @@ export function generate(options: GenerateOptions): GenerateResult {
339
403
  mdx,
340
404
  layouts,
341
405
  objects,
406
+ sources,
342
407
  skippedBlocks: [...skippedBlocks, ...skippedRichtext],
343
408
  skippedMdx,
344
409
  configFile,
@@ -0,0 +1,255 @@
1
+ // packages/core/src/preview-channel.ts
2
+ //
3
+ // In-browser protocol between an editor and a preview page. postMessage when
4
+ // they are different windows; an in-memory pair when they share one (on-site
5
+ // overlay). No framework imports.
6
+
7
+ import { parsePreviewHotkey, type PreviewHotkeyMessage } from "./preview-hotkeys";
8
+
9
+ export const PREVIEW_CHANNEL_VERSION = 1 as const;
10
+
11
+ export type PreviewChannelRef = {
12
+ locale: string;
13
+ type: string;
14
+ slug: string;
15
+ branch?: string;
16
+ tree?: string;
17
+ };
18
+
19
+ type Envelope = {
20
+ v: typeof PREVIEW_CHANNEL_VERSION;
21
+ ref: PreviewChannelRef;
22
+ };
23
+
24
+ export type PreviewHelloMessage = Envelope & {
25
+ type: "backmatter:hello";
26
+ capabilities?: string[];
27
+ };
28
+
29
+ export type PreviewDocMessage = Envelope & {
30
+ type: "backmatter:doc";
31
+ frontmatter: Record<string, unknown>;
32
+ body: string;
33
+ seq: number;
34
+ };
35
+
36
+ export type PreviewNavigateMessage = Envelope & {
37
+ type: "backmatter:navigate";
38
+ path: string;
39
+ };
40
+
41
+ export type PreviewRemovedMessage = Envelope & {
42
+ type: "backmatter:removed";
43
+ };
44
+
45
+ export type PreviewStatusMessage = Envelope & {
46
+ type: "backmatter:status";
47
+ seq: number;
48
+ state: "rendered" | "error";
49
+ message?: string;
50
+ };
51
+
52
+ export type PreviewChannelMessage =
53
+ | PreviewHelloMessage
54
+ | PreviewDocMessage
55
+ | PreviewNavigateMessage
56
+ | PreviewRemovedMessage
57
+ | PreviewStatusMessage
58
+ | PreviewHotkeyMessage;
59
+
60
+ export type PreviewTransport = {
61
+ send(message: PreviewChannelMessage): void;
62
+ subscribe(listener: (message: PreviewChannelMessage) => void): () => void;
63
+ close(): void;
64
+ };
65
+
66
+ export function samePreviewRef(a: PreviewChannelRef, b: PreviewChannelRef): boolean {
67
+ if (a.locale !== b.locale || a.type !== b.type || a.slug !== b.slug) return false;
68
+ if (a.branch && b.branch && a.branch !== b.branch) return false;
69
+ if (a.tree && b.tree && a.tree !== b.tree) return false;
70
+ return true;
71
+ }
72
+
73
+ function isRecord(value: unknown): value is Record<string, unknown> {
74
+ return Boolean(value) && typeof value === "object";
75
+ }
76
+
77
+ function readRef(value: unknown): PreviewChannelRef | null {
78
+ if (!isRecord(value)) return null;
79
+ if (typeof value.locale !== "string" || !value.locale) return null;
80
+ if (typeof value.type !== "string" || !value.type) return null;
81
+ if (typeof value.slug !== "string" || !value.slug) return null;
82
+ const ref: PreviewChannelRef = {
83
+ locale: value.locale,
84
+ type: value.type,
85
+ slug: value.slug,
86
+ };
87
+ if (typeof value.branch === "string" && value.branch) ref.branch = value.branch;
88
+ if (typeof value.tree === "string" && value.tree) ref.tree = value.tree;
89
+ return ref;
90
+ }
91
+
92
+ function readEnvelope(value: Record<string, unknown>): Envelope | null {
93
+ if (value.v !== PREVIEW_CHANNEL_VERSION) return null;
94
+ const ref = readRef(value.ref);
95
+ if (!ref) return null;
96
+ return { v: PREVIEW_CHANNEL_VERSION, ref };
97
+ }
98
+
99
+ /** Parse a channel payload. Legacy hotkey messages (no `v`) still parse. */
100
+ export function parsePreviewMessage(data: unknown): PreviewChannelMessage | null {
101
+ const hotkey = parsePreviewHotkey(data);
102
+ if (hotkey) return hotkey;
103
+ if (!isRecord(data) || typeof data.type !== "string") return null;
104
+ const envelope = readEnvelope(data);
105
+ if (!envelope) return null;
106
+ switch (data.type) {
107
+ case "backmatter:hello": {
108
+ const capabilities = Array.isArray(data.capabilities)
109
+ ? data.capabilities.filter((item): item is string => typeof item === "string")
110
+ : undefined;
111
+ return { type: "backmatter:hello", ...envelope, ...(capabilities ? { capabilities } : {}) };
112
+ }
113
+ case "backmatter:doc": {
114
+ if (!isRecord(data.frontmatter) || Array.isArray(data.frontmatter)) return null;
115
+ if (typeof data.body !== "string") return null;
116
+ if (typeof data.seq !== "number" || !Number.isFinite(data.seq)) return null;
117
+ return {
118
+ type: "backmatter:doc",
119
+ ...envelope,
120
+ frontmatter: data.frontmatter,
121
+ body: data.body,
122
+ seq: data.seq,
123
+ };
124
+ }
125
+ case "backmatter:navigate": {
126
+ if (typeof data.path !== "string" || !data.path.startsWith("/")) return null;
127
+ return { type: "backmatter:navigate", ...envelope, path: data.path };
128
+ }
129
+ case "backmatter:removed":
130
+ return { type: "backmatter:removed", ...envelope };
131
+ case "backmatter:status": {
132
+ if (data.state !== "rendered" && data.state !== "error") return null;
133
+ if (typeof data.seq !== "number" || !Number.isFinite(data.seq)) return null;
134
+ return {
135
+ type: "backmatter:status",
136
+ ...envelope,
137
+ seq: data.seq,
138
+ state: data.state,
139
+ ...(typeof data.message === "string" ? { message: data.message } : {}),
140
+ };
141
+ }
142
+ default:
143
+ return null;
144
+ }
145
+ }
146
+
147
+ export function normalizePreviewOrigin(origin: string): string {
148
+ return origin.trim().replace(/\/$/, "");
149
+ }
150
+
151
+ /** True when `origin` is one of the allowed origins. Empty allow-list rejects all. */
152
+ export function isAllowedPreviewOrigin(origin: string, allowed: readonly string[]): boolean {
153
+ const needle = normalizePreviewOrigin(origin);
154
+ if (!needle) return false;
155
+ return allowed.some((item) => normalizePreviewOrigin(item) === needle);
156
+ }
157
+
158
+ export type PreviewMessageEvent = { origin: string; data: unknown };
159
+
160
+ export type PostMessageTransportOptions = {
161
+ post(message: PreviewChannelMessage, targetOrigin: string): void;
162
+ targetOrigin: string;
163
+ allowedOrigins: readonly string[];
164
+ listen(handler: (event: PreviewMessageEvent) => void): () => void;
165
+ };
166
+
167
+ /** Cross-window transport. Inbound messages from other origins are dropped. */
168
+ export function createPostMessageTransport(options: PostMessageTransportOptions): PreviewTransport {
169
+ const listeners = new Set<(message: PreviewChannelMessage) => void>();
170
+ let closed = false;
171
+ const stop = options.listen((event) => {
172
+ if (closed) return;
173
+ if (!isAllowedPreviewOrigin(event.origin, options.allowedOrigins)) return;
174
+ const message = parsePreviewMessage(event.data);
175
+ if (!message) return;
176
+ for (const listener of listeners) listener(message);
177
+ });
178
+ return {
179
+ send(message) {
180
+ if (closed) return;
181
+ options.post(message, normalizePreviewOrigin(options.targetOrigin));
182
+ },
183
+ subscribe(listener) {
184
+ listeners.add(listener);
185
+ return () => listeners.delete(listener);
186
+ },
187
+ close() {
188
+ if (closed) return;
189
+ closed = true;
190
+ listeners.clear();
191
+ stop();
192
+ },
193
+ };
194
+ }
195
+
196
+ export type PreviewWindowLike = {
197
+ postMessage(message: unknown, targetOrigin: string): void;
198
+ addEventListener(type: "message", handler: (event: MessageEvent) => void): void;
199
+ removeEventListener(type: "message", handler: (event: MessageEvent) => void): void;
200
+ };
201
+
202
+ /** Browser binding for `createPostMessageTransport`. */
203
+ export function createWindowPreviewTransport(options: {
204
+ target: { postMessage(message: unknown, targetOrigin: string): void };
205
+ targetOrigin: string;
206
+ allowedOrigins: readonly string[];
207
+ host?: PreviewWindowLike;
208
+ }): PreviewTransport {
209
+ const host = options.host ?? (globalThis as unknown as PreviewWindowLike);
210
+ return createPostMessageTransport({
211
+ targetOrigin: options.targetOrigin,
212
+ allowedOrigins: options.allowedOrigins,
213
+ post: (message, targetOrigin) => options.target.postMessage(message, targetOrigin),
214
+ listen: (handler) => {
215
+ const onMessage = (event: MessageEvent) => {
216
+ handler({ origin: event.origin, data: event.data });
217
+ };
218
+ host.addEventListener("message", onMessage);
219
+ return () => host.removeEventListener("message", onMessage);
220
+ },
221
+ });
222
+ }
223
+
224
+ /** Paired in-memory transports for an editor and a page in the same window. */
225
+ export function createLocalPreviewTransport(): { editor: PreviewTransport; page: PreviewTransport } {
226
+ const editorListeners = new Set<(message: PreviewChannelMessage) => void>();
227
+ const pageListeners = new Set<(message: PreviewChannelMessage) => void>();
228
+ let closed = false;
229
+
230
+ function side(
231
+ mine: Set<(message: PreviewChannelMessage) => void>,
232
+ other: Set<(message: PreviewChannelMessage) => void>,
233
+ ): PreviewTransport {
234
+ return {
235
+ send(message) {
236
+ if (closed) return;
237
+ for (const listener of other) listener(message);
238
+ },
239
+ subscribe(listener) {
240
+ mine.add(listener);
241
+ return () => mine.delete(listener);
242
+ },
243
+ close() {
244
+ closed = true;
245
+ editorListeners.clear();
246
+ pageListeners.clear();
247
+ },
248
+ };
249
+ }
250
+
251
+ return {
252
+ editor: side(editorListeners, pageListeners),
253
+ page: side(pageListeners, editorListeners),
254
+ };
255
+ }
@@ -0,0 +1,291 @@
1
+ // packages/core/src/preview-client.ts
2
+ //
3
+ // Framework-free preview session. The page applies editor `doc` messages
4
+ // through a renderer. SSE is the source until an editor attaches, and after
5
+ // that only a tombstone (`raw: ""`) from SSE still wins.
6
+
7
+ import {
8
+ samePreviewRef,
9
+ type PreviewChannelMessage,
10
+ type PreviewChannelRef,
11
+ type PreviewTransport,
12
+ } from "./preview-channel";
13
+
14
+ export type PreviewRenderDoc = {
15
+ frontmatter: Record<string, unknown>;
16
+ body: string;
17
+ };
18
+
19
+ export type PreviewRenderResult = { ok: true; value?: unknown } | { ok: false; error: string };
20
+
21
+ export type PreviewRenderer = (
22
+ ref: PreviewChannelRef,
23
+ doc: PreviewRenderDoc,
24
+ ) => Promise<PreviewRenderResult>;
25
+
26
+ /** Per-document SSE payload. `raw: ""` is a working-tree tombstone. */
27
+ export type PreviewSseEvent = {
28
+ raw?: string;
29
+ body?: string;
30
+ frontmatter?: Record<string, unknown>;
31
+ };
32
+
33
+ export type PreviewPageHandlers = {
34
+ onApplied?: (doc: PreviewRenderDoc & { seq: number }, value?: unknown) => void;
35
+ onError?: (message: string) => void;
36
+ onRemoved?: () => void;
37
+ onNavigate?: (path: string) => void;
38
+ };
39
+
40
+ type Job = {
41
+ doc: PreviewRenderDoc;
42
+ source: "channel" | "sse";
43
+ seq: number;
44
+ };
45
+
46
+ export type PreviewPageSession = {
47
+ close(): void;
48
+ /** True after the first `doc` for this page's ref. */
49
+ editorAttached(): boolean;
50
+ };
51
+
52
+ /**
53
+ * Page side. Sends `hello` immediately. At most one render runs; a newer
54
+ * payload replaces a queued one and a finished render is dropped when a
55
+ * newer payload arrived during the call.
56
+ */
57
+ export function createPreviewPage(options: {
58
+ ref: PreviewChannelRef;
59
+ transport: PreviewTransport;
60
+ subscribeSse: (onEvent: (event: PreviewSseEvent) => void) => () => void;
61
+ renderer: PreviewRenderer;
62
+ handlers?: PreviewPageHandlers;
63
+ capabilities?: string[];
64
+ }): PreviewPageSession {
65
+ const handlers = options.handlers ?? {};
66
+ let attached = false;
67
+ let settledSeq = 0;
68
+ let inflightSeq = 0;
69
+ let closed = false;
70
+ let rendering = false;
71
+ let queued: Job | null = null;
72
+ let epoch = 0;
73
+
74
+ function send(message: PreviewChannelMessage) {
75
+ if (closed) return;
76
+ options.transport.send(message);
77
+ }
78
+
79
+ function settle(job: Job, result: PreviewRenderResult) {
80
+ if (job.source === "channel") {
81
+ settledSeq = job.seq;
82
+ send({
83
+ type: "backmatter:status",
84
+ v: 1,
85
+ ref: options.ref,
86
+ seq: job.seq,
87
+ state: result.ok ? "rendered" : "error",
88
+ ...(!result.ok ? { message: result.error } : {}),
89
+ });
90
+ }
91
+ if (result.ok) handlers.onApplied?.({ ...job.doc, seq: job.seq }, result.value);
92
+ else handlers.onError?.(result.error);
93
+ }
94
+
95
+ async function drain() {
96
+ if (rendering || closed) return;
97
+ rendering = true;
98
+ try {
99
+ while (queued && !closed) {
100
+ const job = queued;
101
+ queued = null;
102
+ inflightSeq = job.source === "channel" ? job.seq : 0;
103
+ const epochAtStart = epoch;
104
+ let result: PreviewRenderResult;
105
+ try {
106
+ result = await options.renderer(options.ref, job.doc);
107
+ } catch (err) {
108
+ result = {
109
+ ok: false,
110
+ error: err instanceof Error ? err.message : "Preview render failed",
111
+ };
112
+ }
113
+ if (closed || epoch !== epochAtStart) continue;
114
+ if (queued) continue;
115
+ settle(job, result);
116
+ }
117
+ } finally {
118
+ rendering = false;
119
+ }
120
+ }
121
+
122
+ function enqueue(job: Job) {
123
+ if (closed) return;
124
+ if (job.source === "channel") {
125
+ attached = true;
126
+ if (job.seq <= Math.max(settledSeq, inflightSeq)) return;
127
+ if (queued?.source === "channel" && job.seq <= queued.seq) return;
128
+ }
129
+ queued = job;
130
+ void drain();
131
+ }
132
+
133
+ function showRemoved() {
134
+ epoch += 1;
135
+ queued = null;
136
+ handlers.onRemoved?.();
137
+ }
138
+
139
+ const unsubscribe = options.transport.subscribe((message) => {
140
+ if (closed) return;
141
+ if (message.type === "backmatter:hotkey") return;
142
+ if (message.type !== "backmatter:navigate" && !samePreviewRef(message.ref, options.ref)) return;
143
+ switch (message.type) {
144
+ case "backmatter:doc":
145
+ enqueue({
146
+ source: "channel",
147
+ seq: message.seq,
148
+ doc: { frontmatter: message.frontmatter, body: message.body },
149
+ });
150
+ break;
151
+ case "backmatter:removed":
152
+ showRemoved();
153
+ break;
154
+ case "backmatter:navigate":
155
+ handlers.onNavigate?.(message.path);
156
+ break;
157
+ default:
158
+ break;
159
+ }
160
+ });
161
+
162
+ const unsubscribeSse = options.subscribeSse((event) => {
163
+ if (closed) return;
164
+ if (event.raw === "") {
165
+ showRemoved();
166
+ return;
167
+ }
168
+ if (attached) return;
169
+ if (event.body === undefined && event.frontmatter === undefined) return;
170
+ enqueue({
171
+ source: "sse",
172
+ seq: 0,
173
+ doc: {
174
+ frontmatter: event.frontmatter ?? {},
175
+ body: event.body ?? "",
176
+ },
177
+ });
178
+ });
179
+
180
+ send({
181
+ type: "backmatter:hello",
182
+ v: 1,
183
+ ref: options.ref,
184
+ ...(options.capabilities ? { capabilities: options.capabilities } : {}),
185
+ });
186
+
187
+ return {
188
+ editorAttached: () => attached,
189
+ close() {
190
+ if (closed) return;
191
+ closed = true;
192
+ epoch += 1;
193
+ queued = null;
194
+ unsubscribe();
195
+ unsubscribeSse();
196
+ },
197
+ };
198
+ }
199
+
200
+ export type PreviewSchedule = (run: () => void) => { cancel(): void };
201
+
202
+ function animationFrameSchedule(): PreviewSchedule {
203
+ return (run) => {
204
+ if (typeof requestAnimationFrame === "function") {
205
+ const id = requestAnimationFrame(run);
206
+ return { cancel: () => cancelAnimationFrame(id) };
207
+ }
208
+ const id = setTimeout(run, 0);
209
+ return { cancel: () => clearTimeout(id) };
210
+ };
211
+ }
212
+
213
+ export type PreviewEditorSession = {
214
+ /** Coalesce to at most one `doc` per scheduled frame. `seq` starts at 1. */
215
+ setDoc(doc: PreviewRenderDoc): void;
216
+ navigate(path: string): void;
217
+ removed(): void;
218
+ close(): void;
219
+ };
220
+
221
+ /**
222
+ * Editor side. `onHello` receives every hello, including one for a different
223
+ * document (the host decides whether to offer "Edit this page").
224
+ */
225
+ export function createPreviewEditor(options: {
226
+ transport: PreviewTransport;
227
+ ref: PreviewChannelRef | (() => PreviewChannelRef);
228
+ onHello?: (ref: PreviewChannelRef) => void;
229
+ onStatus?: (status: { seq: number; state: "rendered" | "error"; message?: string }) => void;
230
+ schedule?: PreviewSchedule;
231
+ }): PreviewEditorSession {
232
+ const schedule = options.schedule ?? animationFrameSchedule();
233
+ const currentRef = () => (typeof options.ref === "function" ? options.ref() : options.ref);
234
+ let seq = 0;
235
+ let pending: PreviewRenderDoc | null = null;
236
+ let scheduled: { cancel(): void } | null = null;
237
+ let closed = false;
238
+
239
+ function flush() {
240
+ scheduled = null;
241
+ if (closed || !pending) return;
242
+ const doc = pending;
243
+ pending = null;
244
+ seq += 1;
245
+ options.transport.send({
246
+ type: "backmatter:doc",
247
+ v: 1,
248
+ ref: currentRef(),
249
+ frontmatter: doc.frontmatter,
250
+ body: doc.body,
251
+ seq,
252
+ });
253
+ }
254
+
255
+ const unsubscribe = options.transport.subscribe((message) => {
256
+ if (closed) return;
257
+ if (message.type === "backmatter:hello") options.onHello?.(message.ref);
258
+ if (message.type === "backmatter:status" && samePreviewRef(message.ref, currentRef())) {
259
+ options.onStatus?.({
260
+ seq: message.seq,
261
+ state: message.state,
262
+ ...(message.message ? { message: message.message } : {}),
263
+ });
264
+ }
265
+ });
266
+
267
+ return {
268
+ setDoc(doc) {
269
+ if (closed) return;
270
+ pending = doc;
271
+ if (scheduled) return;
272
+ scheduled = schedule(flush);
273
+ },
274
+ navigate(path) {
275
+ if (closed) return;
276
+ options.transport.send({ type: "backmatter:navigate", v: 1, ref: currentRef(), path });
277
+ },
278
+ removed() {
279
+ if (closed) return;
280
+ options.transport.send({ type: "backmatter:removed", v: 1, ref: currentRef() });
281
+ },
282
+ close() {
283
+ if (closed) return;
284
+ closed = true;
285
+ scheduled?.cancel();
286
+ scheduled = null;
287
+ pending = null;
288
+ unsubscribe();
289
+ },
290
+ };
291
+ }
@@ -2,11 +2,15 @@
2
2
  import { signPayload, verifyPayload } from "./signed-token";
3
3
 
4
4
  export const PREVIEW_COOKIE = "backmatter_preview";
5
+ export const PREVIEW_SESSION_COOKIE = "backmatter_preview_session";
6
+ export const PREVIEW_SESSION_QUERY = "backmatter_session";
7
+ export const PREVIEW_SESSION_STORAGE_KEY = "backmatter_preview_session";
5
8
  export const PREVIEW_QUERY = "backmatter_preview";
6
9
  export const PREVIEW_CHECKOUT_QUERY = "backmatter_checkout";
7
10
  export const PREVIEW_ENABLE_PATH = "/api/backmatter/preview";
8
11
  export const PREVIEW_TICKET_TTL_MS = 10 * 60 * 1000;
9
12
  export const PREVIEW_COOKIE_TTL_MS = 12 * 60 * 60 * 1000;
13
+ export const PREVIEW_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
10
14
 
11
15
  export type PreviewCheckout = {
12
16
  branch?: string;
@@ -19,6 +23,21 @@ export type PreviewTicket = {
19
23
  redirect: string;
20
24
  branch?: string;
21
25
  tree?: string;
26
+ /** Preview session token. The host stores it and drops it from the URL. */
27
+ session?: string;
28
+ exp: number;
29
+ };
30
+
31
+ /** One user, one workspace, one branch, one working tree. */
32
+ export type PreviewSessionClaims = {
33
+ v: 1;
34
+ kind: "preview-session";
35
+ userId: string;
36
+ workspaceId: string;
37
+ branch: string;
38
+ tree: string;
39
+ role: string;
40
+ epoch: number;
22
41
  exp: number;
23
42
  };
24
43
 
@@ -42,6 +61,7 @@ export async function mintPreviewTicket(
42
61
  redirect: string;
43
62
  branch?: string;
44
63
  tree?: string;
64
+ session?: string;
45
65
  exp: number;
46
66
  },
47
67
  ): Promise<string> {
@@ -55,10 +75,25 @@ export async function mintPreviewTicket(
55
75
  exp: input.exp,
56
76
  ...(input.branch ? { branch: input.branch } : {}),
57
77
  ...(input.tree ? { tree: input.tree } : {}),
78
+ ...(input.session ? { session: input.session } : {}),
58
79
  };
59
80
  return signPayload(secret, payload);
60
81
  }
61
82
 
83
+ export async function mintPreviewSession(
84
+ secret: string,
85
+ input: Omit<PreviewSessionClaims, "v" | "kind">,
86
+ ): Promise<string> {
87
+ if (input.tree !== input.userId) {
88
+ throw new Error("preview session tree must be the user");
89
+ }
90
+ if (!input.branch || !input.workspaceId || !input.userId) {
91
+ throw new Error("preview session is missing scope");
92
+ }
93
+ const payload: PreviewSessionClaims = { v: 1, kind: "preview-session", ...input };
94
+ return signPayload(secret, payload);
95
+ }
96
+
62
97
  function asTicket(value: unknown): PreviewTicket | null {
63
98
  if (!value || typeof value !== "object") return null;
64
99
  const v = value as Record<string, unknown>;
@@ -74,13 +109,85 @@ function asTicket(value: unknown): PreviewTicket | null {
74
109
  exp: v.exp,
75
110
  ...(typeof v.branch === "string" && v.branch ? { branch: v.branch } : {}),
76
111
  ...(typeof v.tree === "string" && v.tree ? { tree: v.tree } : {}),
112
+ ...(typeof v.session === "string" && v.session ? { session: v.session } : {}),
113
+ };
114
+ }
115
+
116
+ function asSession(value: unknown): PreviewSessionClaims | null {
117
+ if (!value || typeof value !== "object") return null;
118
+ const v = value as Record<string, unknown>;
119
+ if (v.v !== 1 || v.kind !== "preview-session") return null;
120
+ if (typeof v.userId !== "string" || typeof v.workspaceId !== "string") return null;
121
+ if (typeof v.branch !== "string" || typeof v.tree !== "string" || typeof v.role !== "string") {
122
+ return null;
123
+ }
124
+ if (v.tree !== v.userId) return null;
125
+ if (typeof v.epoch !== "number" || typeof v.exp !== "number" || v.exp <= Date.now()) return null;
126
+ return {
127
+ v: 1,
128
+ kind: "preview-session",
129
+ userId: v.userId,
130
+ workspaceId: v.workspaceId,
131
+ branch: v.branch,
132
+ tree: v.tree,
133
+ role: v.role,
134
+ epoch: v.epoch,
135
+ exp: v.exp,
77
136
  };
78
137
  }
79
138
 
139
+ /** Workspace id from an unverified body, so the server can load the signing secret. */
140
+ export function peekPreviewSessionWorkspace(token: string): string | null {
141
+ const dot = token.lastIndexOf(".");
142
+ if (dot <= 0) return null;
143
+ try {
144
+ const padded = token.slice(0, dot).replace(/-/g, "+").replace(/_/g, "/");
145
+ const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4));
146
+ const json = JSON.parse(atob(padded + pad)) as Record<string, unknown>;
147
+ return json.kind === "preview-session" && typeof json.workspaceId === "string"
148
+ ? json.workspaceId
149
+ : null;
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+
80
155
  export async function readPreviewTicket(secret: string, token: string): Promise<PreviewTicket | null> {
81
156
  return asTicket(await verifyPayload(secret, token));
82
157
  }
83
158
 
159
+ export async function readPreviewSession(
160
+ secret: string,
161
+ token: string | undefined,
162
+ ): Promise<PreviewSessionClaims | null> {
163
+ if (!token) return null;
164
+ return asSession(await verifyPayload(secret, token));
165
+ }
166
+
167
+ /**
168
+ * A session token may read and write only its own tree. A workspace preview
169
+ * token keeps the caller's query.
170
+ */
171
+ export function scopePreviewAccess(
172
+ auth: { mode: "workspace" } | { mode: "session"; branch: string; tree: string },
173
+ query: { branch: string; tree: string | null },
174
+ ): { branch: string; tree: string | null } | null {
175
+ if (auth.mode === "workspace") return query;
176
+ if (query.tree && query.tree !== auth.tree) return null;
177
+ if (query.branch && query.branch !== auth.branch) return null;
178
+ return { branch: auth.branch, tree: auth.tree };
179
+ }
180
+
181
+ /** Pull a session token out of the URL. The returned search no longer contains it. */
182
+ export function takePreviewSessionFromSearch(search: string): { token: string | null; search: string } {
183
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
184
+ const token = params.get(PREVIEW_SESSION_QUERY)?.trim() || null;
185
+ if (!token) return { token: null, search };
186
+ params.delete(PREVIEW_SESSION_QUERY);
187
+ const next = params.toString();
188
+ return { token, search: next ? `?${next}` : "" };
189
+ }
190
+
84
191
  export function previewLocationWithCheckout(
85
192
  origin: string,
86
193
  redirect: string,
package/src/preview.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // packages/core/src/preview.ts
2
- import type { BackmatterDoc } from "./doc";
2
+ import type { BackmatterDoc, BackmatterDocSummary } from "./doc";
3
3
 
4
4
  export type PreviewDocRef = {
5
5
  locale: string;
@@ -164,6 +164,14 @@ export function previewStreamUrl(
164
164
  return `${docPath(options, ref)}/stream?${previewParams(options, ref)}`;
165
165
  }
166
166
 
167
+ /** Tree-wide stream: added, modified, removed, and HEAD moves for one checkout. */
168
+ export function previewTreeStreamUrl(
169
+ options: PreviewClientOptions,
170
+ checkout: { branch?: string; tree?: string },
171
+ ): string {
172
+ return `${baseUrl(options.apiUrl)}/preview/workspaces/${options.workspaceId}/tree/stream?${previewParams(options, { locale: "", type: "", slug: "", ...checkout })}`;
173
+ }
174
+
167
175
  export async function fetchPreviewDoc(
168
176
  options: PreviewClientOptions,
169
177
  ref: PreviewDocRef,
@@ -176,6 +184,38 @@ export async function fetchPreviewDoc(
176
184
  return data.doc;
177
185
  }
178
186
 
187
+ /**
188
+ * HEAD overlaid with the checkout's working tree. Follows cursors so a list
189
+ * page sees every document of the type, not just the first page.
190
+ */
191
+ export async function fetchPreviewDocs(
192
+ options: PreviewClientOptions,
193
+ query: { branch?: string; tree?: string; type?: string; locale?: string },
194
+ ): Promise<BackmatterDocSummary[]> {
195
+ const docs: BackmatterDocSummary[] = [];
196
+ let cursor: string | undefined;
197
+ for (let page = 0; page < 100; page++) {
198
+ const params = previewParams(options, {
199
+ locale: query.locale ?? "",
200
+ type: "",
201
+ slug: "",
202
+ branch: query.branch,
203
+ tree: query.tree,
204
+ });
205
+ if (query.type) params.set("type", query.type);
206
+ if (query.locale) params.set("locale", query.locale);
207
+ if (cursor) params.set("cursor", cursor);
208
+ const url = `${baseUrl(options.apiUrl)}/preview/workspaces/${options.workspaceId}/docs?${params}`;
209
+ const res = await fetch(url, { cache: "no-store" });
210
+ if (!res.ok) throw new Error(`preview listDocs failed: ${res.status}`);
211
+ const data = (await res.json()) as { docs: BackmatterDocSummary[]; nextCursor: string | null };
212
+ docs.push(...data.docs);
213
+ if (!data.nextCursor) break;
214
+ cursor = data.nextCursor;
215
+ }
216
+ return docs;
217
+ }
218
+
179
219
  /** Browser EventSource subscribe to one working tree's saves; returns unsubscribe. */
180
220
  export function subscribePreview(
181
221
  options: PreviewClientOptions,
@@ -203,3 +243,38 @@ export function subscribePreview(
203
243
  source.close();
204
244
  };
205
245
  }
246
+
247
+ export {
248
+ createLocalPreviewTransport,
249
+ createPostMessageTransport,
250
+ createWindowPreviewTransport,
251
+ isAllowedPreviewOrigin,
252
+ normalizePreviewOrigin,
253
+ parsePreviewMessage,
254
+ PREVIEW_CHANNEL_VERSION,
255
+ samePreviewRef,
256
+ } from "./preview-channel";
257
+ export type {
258
+ PostMessageTransportOptions,
259
+ PreviewChannelMessage,
260
+ PreviewChannelRef,
261
+ PreviewDocMessage,
262
+ PreviewHelloMessage,
263
+ PreviewMessageEvent,
264
+ PreviewNavigateMessage,
265
+ PreviewRemovedMessage,
266
+ PreviewStatusMessage,
267
+ PreviewTransport,
268
+ PreviewWindowLike,
269
+ } from "./preview-channel";
270
+ export { createPreviewEditor, createPreviewPage } from "./preview-client";
271
+ export type {
272
+ PreviewEditorSession,
273
+ PreviewPageHandlers,
274
+ PreviewPageSession,
275
+ PreviewRenderDoc,
276
+ PreviewRenderResult,
277
+ PreviewRenderer,
278
+ PreviewSchedule,
279
+ PreviewSseEvent,
280
+ } from "./preview-client";
package/src/source.ts ADDED
@@ -0,0 +1,24 @@
1
+ // packages/core/src/source.ts
2
+ // A source resolves the outside record behind an `external` model's route.
3
+ // Host code only (`sources/{type}.ts`); never serialized into the schema.
4
+
5
+ export type SourceRef = { locale: string; slug: string };
6
+
7
+ export type SourceDef<T = unknown> = {
8
+ /** The outside record for a route hit. `null` means the record does not exist (404). */
9
+ load: (ref: SourceRef) => Promise<T | null>;
10
+ /** Slugs to prerender, per locale. Omit to prerender only slugs that have a CMS file. */
11
+ list?: (locale: string) => Promise<readonly string[]>;
12
+ /** One representative record for template preview. Omit to preview the template with `data: null`. */
13
+ sample?: (locale: string) => Promise<T | null>;
14
+ /** Metadata when the document has no `title`. */
15
+ metadata?: (record: T) => { title?: string };
16
+ };
17
+
18
+ /** Identity helper that validates the definition and keeps the record type. */
19
+ export function defineSource<T>(def: SourceDef<T>): SourceDef<T> {
20
+ if (!def || typeof def !== "object" || typeof def.load !== "function") {
21
+ throw new Error("source.load is required and must be a function");
22
+ }
23
+ return def;
24
+ }