@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backmatter/core",
3
- "version": "0.0.25",
3
+ "version": "0.0.27",
4
4
  "description": "Experimental Backmatter SDK. APIs will break. Not ready for production.",
5
5
  "type": "module",
6
6
  "publishConfig": {
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. A public URL may use any positive width. */
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) return null;
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
 
@@ -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
  }
@@ -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
+ }
@@ -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
  }
@@ -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") options.onHello?.(message.ref);
258
- if (message.type === "backmatter:status" && samePreviewRef(message.ref, currentRef())) {
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,