@backmatter/core 0.0.18 → 0.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/block.ts +14 -3
- package/src/config.ts +29 -0
- package/src/content-committed.ts +16 -0
- package/src/enter.ts +68 -0
- package/src/fields.ts +17 -4
- package/src/index.ts +31 -1
- package/src/model.ts +72 -2
- package/src/node/cli.ts +93 -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/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
|
+
}
|