@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/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
|
+
}
|