@backmatter/core 0.0.25 → 0.0.27
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/config.ts +2 -0
- package/src/index.ts +10 -0
- package/src/media-url.ts +10 -2
- package/src/node/media-cli.ts +1 -1
- package/src/page-nav.ts +67 -0
- package/src/preview-channel.ts +56 -0
- package/src/preview-client.ts +46 -3
- package/src/preview-pointer.ts +63 -0
- package/src/preview.ts +13 -0
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { detectGitBranch } from "./sync-schema";
|
|
|
7
7
|
export type DevBarHostConfig = {
|
|
8
8
|
/** Draw a viewport ring and top pill while the site session is in preview. */
|
|
9
9
|
previewFrame?: boolean;
|
|
10
|
+
/** Bottom corner radius (px) for the preview ring (host window lip below the chrome). */
|
|
11
|
+
previewFrameRadius?: number;
|
|
10
12
|
};
|
|
11
13
|
|
|
12
14
|
export type BackmatterDirs = {
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,8 @@ export type {
|
|
|
10
10
|
export { field, serializeField, parseFieldSnapshot } from "./fields";
|
|
11
11
|
export {
|
|
12
12
|
MAX_MEDIA_BYTES,
|
|
13
|
+
MAX_MEDIA_TEXT,
|
|
14
|
+
MAX_VARIANT_WIDTH,
|
|
13
15
|
MEDIA_CONTENT_TYPES,
|
|
14
16
|
MEDIA_WIDTHS,
|
|
15
17
|
isMediaContentType,
|
|
@@ -92,6 +94,14 @@ export {
|
|
|
92
94
|
sortByRouteSpecificity,
|
|
93
95
|
} from "./routes";
|
|
94
96
|
|
|
97
|
+
export type { PageNavDoc, PageNavItem } from "./page-nav";
|
|
98
|
+
export {
|
|
99
|
+
PAGE_NAV_DEFAULT_LIMIT,
|
|
100
|
+
PAGE_NAV_MAX_LIMIT,
|
|
101
|
+
pageNavItems,
|
|
102
|
+
paginatePageNavItems,
|
|
103
|
+
} from "./page-nav";
|
|
104
|
+
|
|
95
105
|
export type { PathMatch, ContentSource, ParsedContentKey } from "./path";
|
|
96
106
|
export {
|
|
97
107
|
compilePathPattern,
|
package/src/media-url.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
// packages/core/src/media-url.ts
|
|
2
2
|
|
|
3
|
-
/** Widths the catalog lists for the gallery.
|
|
3
|
+
/** Widths the catalog lists for the gallery. */
|
|
4
4
|
export const MEDIA_WIDTHS = [400, 800, 1200, 2000] as const;
|
|
5
5
|
|
|
6
|
+
/** Widest variant the CDN will resize. A wider request is refused. */
|
|
7
|
+
export const MAX_VARIANT_WIDTH = 2000;
|
|
8
|
+
|
|
6
9
|
export type MediaWidth = (typeof MEDIA_WIDTHS)[number];
|
|
7
10
|
|
|
8
11
|
/** Bytes the catalog accepts for one file. */
|
|
9
12
|
export const MAX_MEDIA_BYTES = 20 * 1024 * 1024;
|
|
10
13
|
|
|
14
|
+
/** Characters of alt text or caption the catalog stores on one image. */
|
|
15
|
+
export const MAX_MEDIA_TEXT = 500;
|
|
16
|
+
|
|
11
17
|
export const MEDIA_CONTENT_TYPES = [
|
|
12
18
|
"image/jpeg",
|
|
13
19
|
"image/png",
|
|
@@ -39,7 +45,9 @@ export function mediaVariantUrl(
|
|
|
39
45
|
id: string,
|
|
40
46
|
width: number,
|
|
41
47
|
): string | null {
|
|
42
|
-
if (!isMediaId(id) || !Number.isInteger(width) || width < 1)
|
|
48
|
+
if (!isMediaId(id) || !Number.isInteger(width) || width < 1 || width > MAX_VARIANT_WIDTH) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
43
51
|
return `${origin.replace(/\/$/, "")}/img/${id}/width=${width},format=auto`;
|
|
44
52
|
}
|
|
45
53
|
|
package/src/node/media-cli.ts
CHANGED
|
@@ -112,7 +112,7 @@ async function media(
|
|
|
112
112
|
if (flags.width) {
|
|
113
113
|
const width = Number(flags.width);
|
|
114
114
|
const url = mediaVariantUrl(origin, id, width);
|
|
115
|
-
if (!url) throw new Error("width must be a positive integer");
|
|
115
|
+
if (!url) throw new Error("width must be a positive integer up to 2000");
|
|
116
116
|
io.log(url);
|
|
117
117
|
return 0;
|
|
118
118
|
}
|
package/src/page-nav.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// packages/core/src/page-nav.ts
|
|
2
|
+
import { isRoutable, routeFor, type RouteModel } from "./routes";
|
|
3
|
+
|
|
4
|
+
export type PageNavDoc = {
|
|
5
|
+
locale: string;
|
|
6
|
+
type: string;
|
|
7
|
+
slug: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type PageNavItem = {
|
|
11
|
+
key: string;
|
|
12
|
+
label: string;
|
|
13
|
+
href: string;
|
|
14
|
+
current: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Routable documents, in label order. Unroutable models stay out of the menu. */
|
|
18
|
+
export function pageNavItems(input: {
|
|
19
|
+
docs: readonly PageNavDoc[];
|
|
20
|
+
models: readonly RouteModel[];
|
|
21
|
+
current: PageNavDoc | null;
|
|
22
|
+
}): PageNavItem[] {
|
|
23
|
+
const byType = new Map(input.models.map((model) => [model.type, model]));
|
|
24
|
+
const locales = new Set(input.docs.map((doc) => doc.locale));
|
|
25
|
+
const showLocale = locales.size > 1;
|
|
26
|
+
const items: PageNavItem[] = [];
|
|
27
|
+
for (const doc of input.docs) {
|
|
28
|
+
const model = byType.get(doc.type);
|
|
29
|
+
if (!model || !isRoutable(model)) continue;
|
|
30
|
+
const href = routeFor(model, doc.locale, doc.slug);
|
|
31
|
+
if (!href) continue;
|
|
32
|
+
const name = `${doc.type}/${doc.slug}`;
|
|
33
|
+
items.push({
|
|
34
|
+
key: `${doc.locale}/${doc.type}/${doc.slug}`,
|
|
35
|
+
label: showLocale ? `${name} · ${doc.locale}` : name,
|
|
36
|
+
href,
|
|
37
|
+
current: Boolean(
|
|
38
|
+
input.current &&
|
|
39
|
+
input.current.locale === doc.locale &&
|
|
40
|
+
input.current.type === doc.type &&
|
|
41
|
+
input.current.slug === doc.slug,
|
|
42
|
+
),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
items.sort((a, b) => a.label.localeCompare(b.label));
|
|
46
|
+
return items;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const PAGE_NAV_DEFAULT_LIMIT = 10;
|
|
50
|
+
export const PAGE_NAV_MAX_LIMIT = 50;
|
|
51
|
+
|
|
52
|
+
export function paginatePageNavItems(
|
|
53
|
+
items: readonly PageNavItem[],
|
|
54
|
+
query: { q?: string; limit?: number; offset?: number },
|
|
55
|
+
): { items: PageNavItem[]; total: number } {
|
|
56
|
+
const needle = query.q?.trim().toLowerCase() ?? "";
|
|
57
|
+
const filtered = needle ? items.filter((item) => item.label.toLowerCase().includes(needle)) : [...items];
|
|
58
|
+
const limit = Math.min(
|
|
59
|
+
PAGE_NAV_MAX_LIMIT,
|
|
60
|
+
Math.max(1, query.limit ?? PAGE_NAV_DEFAULT_LIMIT),
|
|
61
|
+
);
|
|
62
|
+
const offset = Math.max(0, query.offset ?? 0);
|
|
63
|
+
return {
|
|
64
|
+
items: filtered.slice(offset, offset + limit),
|
|
65
|
+
total: filtered.length,
|
|
66
|
+
};
|
|
67
|
+
}
|
package/src/preview-channel.ts
CHANGED
|
@@ -49,12 +49,38 @@ export type PreviewStatusMessage = Envelope & {
|
|
|
49
49
|
message?: string;
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
export function normalizePointerQuote(quote: string): string {
|
|
53
|
+
return quote.replace(/\s+/g, " ").trim().slice(0, 240);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type PreviewPointMessage = Envelope & {
|
|
57
|
+
type: "backmatter:point";
|
|
58
|
+
on: boolean;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type PreviewGrabMessage = Envelope & {
|
|
62
|
+
type: "backmatter:grab";
|
|
63
|
+
blockId: string;
|
|
64
|
+
blockType: string | null;
|
|
65
|
+
blockIndex: number | null;
|
|
66
|
+
quote: string;
|
|
67
|
+
shift: boolean;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type PreviewHighlightMessage = Envelope & {
|
|
71
|
+
type: "backmatter:highlight";
|
|
72
|
+
blockId: string | null;
|
|
73
|
+
};
|
|
74
|
+
|
|
52
75
|
export type PreviewChannelMessage =
|
|
53
76
|
| PreviewHelloMessage
|
|
54
77
|
| PreviewDocMessage
|
|
55
78
|
| PreviewNavigateMessage
|
|
56
79
|
| PreviewRemovedMessage
|
|
57
80
|
| PreviewStatusMessage
|
|
81
|
+
| PreviewPointMessage
|
|
82
|
+
| PreviewGrabMessage
|
|
83
|
+
| PreviewHighlightMessage
|
|
58
84
|
| PreviewHotkeyMessage;
|
|
59
85
|
|
|
60
86
|
export type PreviewTransport = {
|
|
@@ -139,6 +165,36 @@ export function parsePreviewMessage(data: unknown): PreviewChannelMessage | null
|
|
|
139
165
|
...(typeof data.message === "string" ? { message: data.message } : {}),
|
|
140
166
|
};
|
|
141
167
|
}
|
|
168
|
+
case "backmatter:point": {
|
|
169
|
+
if (typeof data.on !== "boolean") return null;
|
|
170
|
+
return { type: "backmatter:point", ...envelope, on: data.on };
|
|
171
|
+
}
|
|
172
|
+
case "backmatter:grab": {
|
|
173
|
+
if (typeof data.blockId !== "string" || data.blockId.length === 0) return null;
|
|
174
|
+
if (data.blockType !== null && typeof data.blockType !== "string") return null;
|
|
175
|
+
if (
|
|
176
|
+
data.blockIndex !== null &&
|
|
177
|
+
(typeof data.blockIndex !== "number" || !Number.isInteger(data.blockIndex) || data.blockIndex < 0)
|
|
178
|
+
) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
if (typeof data.quote !== "string" || typeof data.shift !== "boolean") return null;
|
|
182
|
+
return {
|
|
183
|
+
type: "backmatter:grab",
|
|
184
|
+
...envelope,
|
|
185
|
+
blockId: data.blockId,
|
|
186
|
+
blockType: data.blockType,
|
|
187
|
+
blockIndex: data.blockIndex,
|
|
188
|
+
quote: normalizePointerQuote(data.quote),
|
|
189
|
+
shift: data.shift,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
case "backmatter:highlight": {
|
|
193
|
+
if (data.blockId !== null && (typeof data.blockId !== "string" || data.blockId.length === 0)) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
return { type: "backmatter:highlight", ...envelope, blockId: data.blockId };
|
|
197
|
+
}
|
|
142
198
|
default:
|
|
143
199
|
return null;
|
|
144
200
|
}
|
package/src/preview-client.ts
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
// that only a tombstone (`raw: ""`) from SSE still wins.
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
|
+
normalizePointerQuote,
|
|
8
9
|
samePreviewRef,
|
|
9
10
|
type PreviewChannelMessage,
|
|
10
11
|
type PreviewChannelRef,
|
|
12
|
+
type PreviewGrabMessage,
|
|
11
13
|
type PreviewTransport,
|
|
12
14
|
} from "./preview-channel";
|
|
13
15
|
|
|
@@ -35,6 +37,8 @@ export type PreviewPageHandlers = {
|
|
|
35
37
|
onError?: (message: string) => void;
|
|
36
38
|
onRemoved?: () => void;
|
|
37
39
|
onNavigate?: (path: string) => void;
|
|
40
|
+
onPoint?: (on: boolean) => void;
|
|
41
|
+
onHighlight?: (blockId: string | null) => void;
|
|
38
42
|
};
|
|
39
43
|
|
|
40
44
|
type Job = {
|
|
@@ -47,6 +51,8 @@ export type PreviewPageSession = {
|
|
|
47
51
|
close(): void;
|
|
48
52
|
/** True after the first `doc` for this page's ref. */
|
|
49
53
|
editorAttached(): boolean;
|
|
54
|
+
grab(grab: Omit<PreviewGrabMessage, "type" | "v" | "ref">): void;
|
|
55
|
+
point(on: boolean): void;
|
|
50
56
|
};
|
|
51
57
|
|
|
52
58
|
/**
|
|
@@ -154,6 +160,12 @@ export function createPreviewPage(options: {
|
|
|
154
160
|
case "backmatter:navigate":
|
|
155
161
|
handlers.onNavigate?.(message.path);
|
|
156
162
|
break;
|
|
163
|
+
case "backmatter:point":
|
|
164
|
+
handlers.onPoint?.(message.on);
|
|
165
|
+
break;
|
|
166
|
+
case "backmatter:highlight":
|
|
167
|
+
handlers.onHighlight?.(message.blockId);
|
|
168
|
+
break;
|
|
157
169
|
default:
|
|
158
170
|
break;
|
|
159
171
|
}
|
|
@@ -186,6 +198,18 @@ export function createPreviewPage(options: {
|
|
|
186
198
|
|
|
187
199
|
return {
|
|
188
200
|
editorAttached: () => attached,
|
|
201
|
+
grab(grab) {
|
|
202
|
+
send({
|
|
203
|
+
type: "backmatter:grab",
|
|
204
|
+
v: 1,
|
|
205
|
+
ref: options.ref,
|
|
206
|
+
...grab,
|
|
207
|
+
quote: normalizePointerQuote(grab.quote),
|
|
208
|
+
});
|
|
209
|
+
},
|
|
210
|
+
point(on) {
|
|
211
|
+
send({ type: "backmatter:point", v: 1, ref: options.ref, on });
|
|
212
|
+
},
|
|
189
213
|
close() {
|
|
190
214
|
if (closed) return;
|
|
191
215
|
closed = true;
|
|
@@ -215,6 +239,8 @@ export type PreviewEditorSession = {
|
|
|
215
239
|
setDoc(doc: PreviewRenderDoc): void;
|
|
216
240
|
navigate(path: string): void;
|
|
217
241
|
removed(): void;
|
|
242
|
+
point(on: boolean): void;
|
|
243
|
+
highlight(blockId: string | null): void;
|
|
218
244
|
close(): void;
|
|
219
245
|
};
|
|
220
246
|
|
|
@@ -225,7 +251,9 @@ export type PreviewEditorSession = {
|
|
|
225
251
|
export function createPreviewEditor(options: {
|
|
226
252
|
transport: PreviewTransport;
|
|
227
253
|
ref: PreviewChannelRef | (() => PreviewChannelRef);
|
|
228
|
-
onHello?: (ref: PreviewChannelRef) => void;
|
|
254
|
+
onHello?: (ref: PreviewChannelRef, capabilities?: string[]) => void;
|
|
255
|
+
onGrab?: (grab: PreviewGrabMessage) => void;
|
|
256
|
+
onPoint?: (on: boolean) => void;
|
|
229
257
|
onStatus?: (status: { seq: number; state: "rendered" | "error"; message?: string }) => void;
|
|
230
258
|
schedule?: PreviewSchedule;
|
|
231
259
|
}): PreviewEditorSession {
|
|
@@ -254,14 +282,21 @@ export function createPreviewEditor(options: {
|
|
|
254
282
|
|
|
255
283
|
const unsubscribe = options.transport.subscribe((message) => {
|
|
256
284
|
if (closed) return;
|
|
257
|
-
if (message.type === "backmatter:hello")
|
|
258
|
-
|
|
285
|
+
if (message.type === "backmatter:hello") {
|
|
286
|
+
options.onHello?.(message.ref, message.capabilities);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (message.type === "backmatter:hotkey") return;
|
|
290
|
+
if (!samePreviewRef(message.ref, currentRef())) return;
|
|
291
|
+
if (message.type === "backmatter:status") {
|
|
259
292
|
options.onStatus?.({
|
|
260
293
|
seq: message.seq,
|
|
261
294
|
state: message.state,
|
|
262
295
|
...(message.message ? { message: message.message } : {}),
|
|
263
296
|
});
|
|
264
297
|
}
|
|
298
|
+
if (message.type === "backmatter:grab") options.onGrab?.(message);
|
|
299
|
+
if (message.type === "backmatter:point") options.onPoint?.(message.on);
|
|
265
300
|
});
|
|
266
301
|
|
|
267
302
|
return {
|
|
@@ -279,6 +314,14 @@ export function createPreviewEditor(options: {
|
|
|
279
314
|
if (closed) return;
|
|
280
315
|
options.transport.send({ type: "backmatter:removed", v: 1, ref: currentRef() });
|
|
281
316
|
},
|
|
317
|
+
point(on) {
|
|
318
|
+
if (closed) return;
|
|
319
|
+
options.transport.send({ type: "backmatter:point", v: 1, ref: currentRef(), on });
|
|
320
|
+
},
|
|
321
|
+
highlight(blockId) {
|
|
322
|
+
if (closed) return;
|
|
323
|
+
options.transport.send({ type: "backmatter:highlight", v: 1, ref: currentRef(), blockId });
|
|
324
|
+
},
|
|
282
325
|
close() {
|
|
283
326
|
if (closed) return;
|
|
284
327
|
closed = true;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// packages/core/src/preview-pointer.ts
|
|
2
|
+
|
|
3
|
+
export type PointerArmSource = "editor" | "devbar";
|
|
4
|
+
|
|
5
|
+
export type PointerHit = {
|
|
6
|
+
blockId: string | null;
|
|
7
|
+
blockType: string | null;
|
|
8
|
+
blockIndex: number | null;
|
|
9
|
+
quote: string;
|
|
10
|
+
shift: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type RoutedGrab = {
|
|
14
|
+
blockId: string;
|
|
15
|
+
blockType: string | null;
|
|
16
|
+
blockIndex: number | null;
|
|
17
|
+
quote: string;
|
|
18
|
+
shift: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type PointerRoute =
|
|
22
|
+
| { to: "editor"; grab: RoutedGrab }
|
|
23
|
+
| { to: "devbar"; grab: Omit<PointerHit, "shift"> }
|
|
24
|
+
| { to: "none" };
|
|
25
|
+
|
|
26
|
+
export function routePointerHit(source: PointerArmSource | null, hit: PointerHit): PointerRoute {
|
|
27
|
+
if (hit.shift && hit.blockId) {
|
|
28
|
+
return {
|
|
29
|
+
to: "editor",
|
|
30
|
+
grab: {
|
|
31
|
+
blockId: hit.blockId,
|
|
32
|
+
blockType: hit.blockType,
|
|
33
|
+
blockIndex: hit.blockIndex,
|
|
34
|
+
quote: hit.quote,
|
|
35
|
+
shift: true,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (source === "devbar") {
|
|
40
|
+
return {
|
|
41
|
+
to: "devbar",
|
|
42
|
+
grab: {
|
|
43
|
+
blockId: hit.blockId,
|
|
44
|
+
blockType: hit.blockType,
|
|
45
|
+
blockIndex: hit.blockIndex,
|
|
46
|
+
quote: hit.quote,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (source === "editor" && hit.blockId) {
|
|
51
|
+
return {
|
|
52
|
+
to: "editor",
|
|
53
|
+
grab: {
|
|
54
|
+
blockId: hit.blockId,
|
|
55
|
+
blockType: hit.blockType,
|
|
56
|
+
blockIndex: hit.blockIndex,
|
|
57
|
+
quote: hit.quote,
|
|
58
|
+
shift: false,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return { to: "none" };
|
|
63
|
+
}
|
package/src/preview.ts
CHANGED
|
@@ -304,6 +304,7 @@ export {
|
|
|
304
304
|
createWindowPreviewTransport,
|
|
305
305
|
isAllowedPreviewOrigin,
|
|
306
306
|
normalizePreviewOrigin,
|
|
307
|
+
normalizePointerQuote,
|
|
307
308
|
parsePreviewMessage,
|
|
308
309
|
PREVIEW_CHANNEL_VERSION,
|
|
309
310
|
samePreviewRef,
|
|
@@ -313,14 +314,26 @@ export type {
|
|
|
313
314
|
PreviewChannelMessage,
|
|
314
315
|
PreviewChannelRef,
|
|
315
316
|
PreviewDocMessage,
|
|
317
|
+
PreviewGrabMessage,
|
|
316
318
|
PreviewHelloMessage,
|
|
319
|
+
PreviewHighlightMessage,
|
|
317
320
|
PreviewMessageEvent,
|
|
318
321
|
PreviewNavigateMessage,
|
|
322
|
+
PreviewPointMessage,
|
|
319
323
|
PreviewRemovedMessage,
|
|
320
324
|
PreviewStatusMessage,
|
|
321
325
|
PreviewTransport,
|
|
322
326
|
PreviewWindowLike,
|
|
323
327
|
} from "./preview-channel";
|
|
328
|
+
export {
|
|
329
|
+
routePointerHit,
|
|
330
|
+
} from "./preview-pointer";
|
|
331
|
+
export type {
|
|
332
|
+
PointerArmSource,
|
|
333
|
+
PointerHit,
|
|
334
|
+
PointerRoute,
|
|
335
|
+
RoutedGrab,
|
|
336
|
+
} from "./preview-pointer";
|
|
324
337
|
export { createPreviewEditor, createPreviewPage } from "./preview-client";
|
|
325
338
|
export type {
|
|
326
339
|
PreviewEditorSession,
|