@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 +1 -1
- package/src/block.ts +14 -3
- package/src/config.ts +4 -0
- package/src/content-committed.ts +16 -0
- package/src/index.ts +17 -1
- package/src/model.ts +72 -2
- package/src/node/cli.ts +24 -3
- package/src/node/discover.ts +5 -0
- package/src/node/generate.ts +67 -2
- package/src/preview-channel.ts +255 -0
- package/src/preview-client.ts +291 -0
- package/src/preview-session.ts +107 -0
- package/src/preview.ts +76 -1
- package/src/source.ts +24 -0
package/package.json
CHANGED
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
|
-
/**
|
|
62
|
-
|
|
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
|
}
|
package/src/content-committed.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
package/src/node/discover.ts
CHANGED
|
@@ -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",
|
package/src/node/generate.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|
package/src/preview-session.ts
CHANGED
|
@@ -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
|
+
}
|