ncblock 0.0.4 → 0.0.5

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.
Files changed (56) hide show
  1. package/HOST.md +68 -0
  2. package/bridge/SandboxBridge.ts +659 -0
  3. package/bridge/context.ts +58 -0
  4. package/bridge/dataSources/dataSource.ts +63 -0
  5. package/bridge/dataSources/dataSourcePage.ts +69 -0
  6. package/bridge/dataSources/dataSourceValue.ts +19 -0
  7. package/bridge/dataSources/dateValue.ts +96 -0
  8. package/bridge/dataSources/propertySchema.ts +186 -0
  9. package/bridge/dataSources/recordPointer.ts +13 -0
  10. package/bridge/dataSources/resolve.ts +96 -0
  11. package/bridge/dataSources/resolveProperty.ts +96 -0
  12. package/bridge/hostState.ts +146 -0
  13. package/bridge/ids.ts +30 -0
  14. package/bridge/incomingType.ts +19 -0
  15. package/bridge/loadManifest.ts +54 -0
  16. package/bridge/manifest.ts +53 -0
  17. package/bridge/messages/contextChanged.ts +15 -0
  18. package/bridge/messages/createPage.ts +64 -0
  19. package/bridge/messages/createPageResult.ts +25 -0
  20. package/bridge/messages/dataSourcesChanged.ts +18 -0
  21. package/bridge/messages/getPage.ts +32 -0
  22. package/bridge/messages/getUser.ts +32 -0
  23. package/bridge/messages/hostToSandbox.ts +33 -0
  24. package/bridge/messages/init.ts +20 -0
  25. package/bridge/messages/invalidHostMessage.ts +16 -0
  26. package/bridge/messages/invalidSandboxMessage.ts +18 -0
  27. package/bridge/messages/listUsers.ts +33 -0
  28. package/bridge/messages/queryDataSource.ts +16 -0
  29. package/bridge/messages/queryDataSourceResult.ts +18 -0
  30. package/bridge/messages/ready.ts +25 -0
  31. package/bridge/messages/resize.ts +13 -0
  32. package/bridge/messages/sandboxToHost.ts +30 -0
  33. package/bridge/messages/themeChanged.ts +15 -0
  34. package/bridge/messages/updatePage.ts +21 -0
  35. package/bridge/messages/updatePageResult.ts +24 -0
  36. package/bridge/pages/page.ts +314 -0
  37. package/bridge/pendingRequests.ts +28 -0
  38. package/bridge/sandboxClient.ts +112 -0
  39. package/bridge/theme.ts +5 -0
  40. package/bridge/users/user.ts +31 -0
  41. package/docs/context.md +45 -0
  42. package/docs/data-sources.md +161 -0
  43. package/docs/lifecycle.md +92 -0
  44. package/docs/manifest.md +42 -0
  45. package/docs/pages.md +143 -0
  46. package/docs/users.md +61 -0
  47. package/host.ts +67 -0
  48. package/index.ts +86 -0
  49. package/init.ts +92 -0
  50. package/package.json +15 -5
  51. package/react.tsx +418 -0
  52. package/types.ts +157 -0
  53. package/users.ts +26 -0
  54. package/utils.ts +13 -0
  55. package/vite-plugin/index.d.ts +46 -0
  56. package/vite-plugin/index.js +115 -0
@@ -0,0 +1,659 @@
1
+ import * as v from "valibot"
2
+ import type {
3
+ CreatePageInput,
4
+ CreatePageResult,
5
+ GetPageResult,
6
+ GetUserResult,
7
+ ListUsersInput,
8
+ ListUsersResult,
9
+ NotionPageId,
10
+ NotionUserId,
11
+ UpdatePageInput,
12
+ UpdatePageResult,
13
+ } from "../types"
14
+ import { unreachable } from "../utils"
15
+ import type { NotionCustomBlockContext } from "./context"
16
+ import type { NotionDataSource } from "./dataSources/dataSource"
17
+ import type {
18
+ NotionDataSourcePageUpdateInput,
19
+ NotionDataSourcePageUpdateResult,
20
+ } from "./dataSources/dataSourcePage"
21
+ import { resolveDataSources } from "./dataSources/resolve"
22
+ import { resolvePropertyWriteMapForDataSource } from "./dataSources/resolveProperty"
23
+ import {
24
+ type CustomBlockHostState,
25
+ createEmptyDataSourceQueryState,
26
+ type DataSourceQueryState,
27
+ } from "./hostState"
28
+ import { readIncomingType } from "./incomingType"
29
+ import type { CustomBlockManifest } from "./manifest"
30
+ import type {
31
+ CreatePageMessage,
32
+ CreatePageMessageParent,
33
+ } from "./messages/createPage"
34
+ import type { GetUserMessage } from "./messages/getUser"
35
+ import { hostToSandboxMessageSchema } from "./messages/hostToSandbox"
36
+ import type { InitMessage } from "./messages/init"
37
+ import type { InvalidHostMessage } from "./messages/invalidHostMessage"
38
+ import type { ListUsersMessage } from "./messages/listUsers"
39
+ import type { ReadyMessage } from "./messages/ready"
40
+ import type { ResizeMessage } from "./messages/resize"
41
+ import type { UpdatePageMessage } from "./messages/updatePage"
42
+ import type { NotionPage } from "./pages/page"
43
+ import { PendingRequests } from "./pendingRequests"
44
+ import type { NotionUser } from "./users/user"
45
+
46
+ /**
47
+ * Used to ensure that the host and client are using the same version of the bridge protocol. A
48
+ * single host needs to support multiple custom blocks built with different versions of the bridge
49
+ * protocol. Increment this number any time a breaking change is made to the bridge protocol.
50
+ */
51
+ export const CUSTOM_BLOCK_BRIDGE_PROTOCOL_VERSION = 1
52
+
53
+ export class SandboxBridge {
54
+ private hostState: CustomBlockHostState = {
55
+ status: "uninitialized",
56
+ theme: "light",
57
+ }
58
+ private listeners = new Set<() => void>()
59
+ private nextRequestId = 1
60
+ private readonly pendingCreatePage = new PendingRequests<CreatePageResult>(
61
+ "custom-block-create-page",
62
+ )
63
+ private readonly pendingGetPage = new PendingRequests<GetPageResult>(
64
+ "custom-block-get-page",
65
+ )
66
+ private readonly pendingGetUser = new PendingRequests<GetUserResult>(
67
+ "custom-block-get-user",
68
+ )
69
+ private readonly pendingListUsers = new PendingRequests<ListUsersResult>(
70
+ "custom-block-list-users",
71
+ )
72
+ private readonly pendingUpdatePage = new PendingRequests<UpdatePageResult>(
73
+ "custom-block-update-page",
74
+ )
75
+ private resolveInit: ((message: InitMessage) => void) | undefined
76
+ private readonly initMessage: Promise<InitMessage> = new Promise(resolve => {
77
+ this.resolveInit = resolve
78
+ })
79
+ private manifest: CustomBlockManifest | null = null
80
+
81
+ constructor() {
82
+ // `ready` is sent later by `initCustomBlock` (after the manifest fetch
83
+ // resolves). Top-level / no-iframe rejection is handled there too, so
84
+ // the constructor just attaches the listener.
85
+ if (typeof window !== "undefined") {
86
+ window.addEventListener("message", this.handleMessage)
87
+ }
88
+ }
89
+
90
+ awaitInit(signal?: AbortSignal): Promise<InitMessage> {
91
+ if (!signal) {
92
+ return this.initMessage
93
+ }
94
+ return new Promise((resolve, reject) => {
95
+ if (signal.aborted) {
96
+ reject(signal.reason)
97
+ return
98
+ }
99
+ const onAbort = () => reject(signal.reason)
100
+ signal.addEventListener("abort", onAbort, { once: true })
101
+ this.initMessage.then(
102
+ message => {
103
+ signal.removeEventListener("abort", onAbort)
104
+ resolve(message)
105
+ },
106
+ err => {
107
+ signal.removeEventListener("abort", onAbort)
108
+ reject(err)
109
+ },
110
+ )
111
+ })
112
+ }
113
+
114
+ sendReady(manifest: CustomBlockManifest | null) {
115
+ if (typeof window === "undefined") {
116
+ return
117
+ }
118
+ this.manifest = manifest
119
+ const readyMessage: ReadyMessage = {
120
+ type: "ready",
121
+ bridgeProtocolVersion: CUSTOM_BLOCK_BRIDGE_PROTOCOL_VERSION,
122
+ manifest,
123
+ }
124
+ console.debug("[notion-custom-sdk] outbound postMessage", readyMessage)
125
+ window.parent.postMessage(readyMessage, "*")
126
+ }
127
+
128
+ private notify = () => {
129
+ for (const listener of this.listeners) {
130
+ listener()
131
+ }
132
+ }
133
+
134
+ private handleMessage = (event: MessageEvent) => {
135
+ console.debug("[notion-custom-sdk] incoming postMessage", {
136
+ data: event.data,
137
+ fromParent: event.source === window.parent,
138
+ })
139
+ if (event.source !== window.parent) {
140
+ return
141
+ }
142
+
143
+ const parsed = v.safeParse(hostToSandboxMessageSchema, event.data)
144
+ if (!parsed.success) {
145
+ console.warn(
146
+ "[notion-custom-sdk] ignoring malformed host message",
147
+ parsed.issues,
148
+ )
149
+ const incomingType = readIncomingType(event.data)
150
+ // Loop guard: never NACK a NACK. Excludes both directions —
151
+ // `invalidSandboxMessage` is what the host normally sends, but
152
+ // a buggy host that echoes our own `invalidHostMessage` back
153
+ // would otherwise spin a NACK feedback loop.
154
+ if (
155
+ incomingType !== "invalidSandboxMessage" &&
156
+ incomingType !== "invalidHostMessage"
157
+ ) {
158
+ const nack: InvalidHostMessage = {
159
+ type: "invalidHostMessage",
160
+ reason: formatInvalidHostReason(incomingType, parsed.issues),
161
+ }
162
+ console.debug("[notion-custom-sdk] outbound postMessage", nack)
163
+ window.parent.postMessage(nack, "*")
164
+ }
165
+ return
166
+ }
167
+ const message = parsed.output
168
+
169
+ // If the host couldn't parse one of our outbound sandbox-to-host messages, it sends back
170
+ // an `invalidSandboxMessage` NACK with a human-readable `reason`. Log it for developer
171
+ // visibility and stop. We don't retry as the sandbox can't recover from a host-side parse
172
+ // failure on its own.
173
+ if (message.type === "invalidSandboxMessage") {
174
+ console.warn(
175
+ "[notion-custom-sdk] host reported invalid sandbox message:",
176
+ message.reason,
177
+ )
178
+ return
179
+ }
180
+
181
+ // `init` is the only message valid before initialization. Handle it up
182
+ // front so every later case can assume `status === "initialized"`.
183
+ if (message.type === "init") {
184
+ this.applyInit(message)
185
+ return
186
+ }
187
+
188
+ // Alias to keep TS's narrowed `InitializedHostState` across the switch
189
+ // below. Reading `this.hostState` repeatedly would re-widen it.
190
+ const hostState = this.hostState
191
+
192
+ if (hostState.status !== "initialized") {
193
+ console.warn(`[notion-custom-sdk] ignoring ${message.type} before init`)
194
+ return
195
+ }
196
+
197
+ switch (message.type) {
198
+ case "themeChanged": {
199
+ this.hostState = {
200
+ ...hostState,
201
+ theme: message.theme,
202
+ }
203
+ this.notify()
204
+ return
205
+ }
206
+
207
+ case "contextChanged": {
208
+ this.hostState = {
209
+ ...hostState,
210
+ context: message.context as NotionCustomBlockContext,
211
+ }
212
+ this.notify()
213
+ return
214
+ }
215
+
216
+ case "dataSourcesChanged": {
217
+ const dataSources = resolveDataSources({
218
+ manifest: this.manifest,
219
+ dataSourceBindings: message.dataSources.bindings,
220
+ })
221
+ // Drop cached query state for keys that no longer exist in the mapping.
222
+ const nextKeys = new Set(dataSources.map(s => s.key))
223
+ const prunedState: Record<string, DataSourceQueryState> = {}
224
+ for (const [key, state] of Object.entries(hostState.dataSourceState)) {
225
+ if (nextKeys.has(key)) {
226
+ prunedState[key] = state
227
+ }
228
+ }
229
+ this.hostState = {
230
+ ...hostState,
231
+ dataSources,
232
+ dataSourceState: prunedState,
233
+ }
234
+ this.notify()
235
+ return
236
+ }
237
+
238
+ case "createPageResult": {
239
+ const result: CreatePageResult =
240
+ message.status === "success"
241
+ ? { status: "success", page: message.page as unknown as NotionPage }
242
+ : { status: "error", error: message.error }
243
+ if (!this.pendingCreatePage.resolve(message.requestId, result)) {
244
+ console.warn(
245
+ `[notion-custom-sdk] createPageResult for unknown requestId ${message.requestId}`,
246
+ )
247
+ }
248
+ return
249
+ }
250
+
251
+ case "getPageResult": {
252
+ const result: GetPageResult =
253
+ message.status === "success"
254
+ ? { status: "success", page: message.page as unknown as NotionPage }
255
+ : { status: "error", error: message.error }
256
+ if (!this.pendingGetPage.resolve(message.requestId, result)) {
257
+ console.warn(
258
+ `[notion-custom-sdk] getPageResult for unknown requestId ${message.requestId}`,
259
+ )
260
+ }
261
+ return
262
+ }
263
+
264
+ case "getUserResult": {
265
+ const result: GetUserResult =
266
+ message.status === "success"
267
+ ? { status: "success", user: message.user as unknown as NotionUser }
268
+ : { status: "error", error: message.error }
269
+ if (!this.pendingGetUser.resolve(message.requestId, result)) {
270
+ console.warn(
271
+ `[notion-custom-sdk] getUserResult for unknown requestId ${message.requestId}`,
272
+ )
273
+ }
274
+ return
275
+ }
276
+
277
+ case "listUsersResult": {
278
+ const result: ListUsersResult =
279
+ message.status === "success"
280
+ ? { status: "success", list: message.list }
281
+ : { status: "error", error: message.error }
282
+ if (!this.pendingListUsers.resolve(message.requestId, result)) {
283
+ console.warn(
284
+ `[notion-custom-sdk] listUsersResult for unknown requestId ${message.requestId}`,
285
+ )
286
+ }
287
+ return
288
+ }
289
+
290
+ case "updatePageResult": {
291
+ const result: UpdatePageResult =
292
+ message.status === "success"
293
+ ? { status: "success", page: message.page as unknown as NotionPage }
294
+ : { status: "error", error: message.error }
295
+ if (!this.pendingUpdatePage.resolve(message.requestId, result)) {
296
+ console.warn(
297
+ `[notion-custom-sdk] updatePageResult for unknown requestId ${message.requestId}`,
298
+ )
299
+ }
300
+ return
301
+ }
302
+
303
+ case "queryDataSourceResult": {
304
+ const currentState =
305
+ hostState.dataSourceState[message.key] ??
306
+ createEmptyDataSourceQueryState()
307
+ if (currentState.latestRequestId !== message.requestId) {
308
+ return
309
+ }
310
+
311
+ this.hostState = {
312
+ ...hostState,
313
+ dataSourceState: {
314
+ ...hostState.dataSourceState,
315
+ [message.key]: {
316
+ items: message.items,
317
+ isLoading: false,
318
+ hasMore: message.hasMore,
319
+ error: message.error,
320
+ // Keep the request ID so later host-pushed refreshes for the
321
+ // same subscription still match.
322
+ latestRequestId: message.requestId,
323
+ },
324
+ },
325
+ }
326
+ this.notify()
327
+ return
328
+ }
329
+
330
+ default: {
331
+ unreachable(message)
332
+ }
333
+ }
334
+ }
335
+
336
+ subscribe(listener: () => void) {
337
+ this.listeners.add(listener)
338
+ return () => this.listeners.delete(listener)
339
+ }
340
+
341
+ getHostState(): CustomBlockHostState {
342
+ return this.hostState
343
+ }
344
+
345
+ /**
346
+ * Apply an `init` payload as if it had arrived from the host. Lets callers
347
+ * seed the bridge directly (e.g. the React provider's standalone preview
348
+ * fallback) without going through `postMessage`. The bridge stays unaware
349
+ * of why it's being seeded.
350
+ */
351
+ setMockState(message: InitMessage) {
352
+ this.applyInit(message)
353
+ }
354
+
355
+ private applyInit(message: InitMessage) {
356
+ const dataSources = resolveDataSources({
357
+ manifest: this.manifest,
358
+ dataSourceBindings: message.dataSources.bindings,
359
+ })
360
+ this.hostState = {
361
+ status: "initialized",
362
+ theme: message.theme,
363
+ context: message.context as NotionCustomBlockContext,
364
+ dataSources,
365
+ dataSourceState: {},
366
+ }
367
+ this.notify()
368
+ // Resolve the awaitInit promise once. Subsequent `init` messages
369
+ // (the host shouldn't send these, but be tolerant) update state but
370
+ // don't re-resolve.
371
+ if (this.resolveInit) {
372
+ this.resolveInit(message)
373
+ this.resolveInit = undefined
374
+ }
375
+ }
376
+
377
+ queryDataSource(key: string, limit: number) {
378
+ if (this.hostState.status !== "initialized") {
379
+ return
380
+ }
381
+
382
+ const currentState =
383
+ this.hostState.dataSourceState[key] ?? createEmptyDataSourceQueryState()
384
+
385
+ if (currentState.isLoading) {
386
+ return
387
+ }
388
+
389
+ const requestId = `custom-block-query-${this.nextRequestId}`
390
+ this.nextRequestId += 1
391
+
392
+ this.hostState = {
393
+ ...this.hostState,
394
+ dataSourceState: {
395
+ ...this.hostState.dataSourceState,
396
+ [key]: {
397
+ ...currentState,
398
+ isLoading: true,
399
+ error: undefined,
400
+ latestRequestId: requestId,
401
+ },
402
+ },
403
+ }
404
+ this.notify()
405
+
406
+ const outbound = {
407
+ type: "queryDataSource",
408
+ requestId,
409
+ key,
410
+ limit,
411
+ }
412
+ console.debug("[notion-custom-sdk] outbound postMessage", outbound)
413
+ window.parent.postMessage(outbound, "*")
414
+ }
415
+
416
+ postResize(height: number) {
417
+ if (typeof window === "undefined") {
418
+ return
419
+ }
420
+ const safeHeight =
421
+ Number.isFinite(height) && height >= 0 ? Math.ceil(height) : 0
422
+ const outbound: ResizeMessage = {
423
+ type: "resize",
424
+ height: safeHeight,
425
+ }
426
+ console.debug("[notion-custom-sdk] outbound postMessage", outbound)
427
+ window.parent.postMessage(outbound, "*")
428
+ }
429
+
430
+ createPage(input: CreatePageInput): Promise<CreatePageResult> {
431
+ return new Promise(resolve => {
432
+ const resolvedParent = this.resolveCreatePageParent(input.parent)
433
+ if (resolvedParent.status === "error") {
434
+ resolve(resolvedParent)
435
+ return
436
+ }
437
+ const resolvedProperties = resolvePropertyWriteMapForDataSource({
438
+ dataSource: resolvedParent.dataSource,
439
+ properties: input.properties,
440
+ operationName: "createPage",
441
+ })
442
+ if (resolvedProperties.status === "error") {
443
+ resolve(resolvedProperties)
444
+ return
445
+ }
446
+ const requestId = this.pendingCreatePage.allocate(resolve)
447
+ const outbound: CreatePageMessage = {
448
+ type: "createPage",
449
+ requestId,
450
+ parent: resolvedParent.parent,
451
+ properties: resolvedProperties.properties,
452
+ }
453
+ if (input.icon !== undefined) {
454
+ outbound.icon = input.icon
455
+ }
456
+ if (input.cover !== undefined) {
457
+ outbound.cover = input.cover
458
+ }
459
+ if (input.position !== undefined) {
460
+ outbound.position = input.position
461
+ }
462
+ console.debug("[notion-custom-sdk] outbound postMessage", outbound)
463
+ window.parent.postMessage(outbound, "*")
464
+ })
465
+ }
466
+
467
+ getPage(pageId: NotionPageId): Promise<GetPageResult> {
468
+ return new Promise(resolve => {
469
+ const requestId = this.pendingGetPage.allocate(resolve)
470
+ const outbound = {
471
+ type: "getPage",
472
+ requestId,
473
+ pageId,
474
+ }
475
+ console.debug("[notion-custom-sdk] outbound postMessage", outbound)
476
+ window.parent.postMessage(outbound, "*")
477
+ })
478
+ }
479
+
480
+ getUser(userId: NotionUserId): Promise<GetUserResult> {
481
+ return new Promise(resolve => {
482
+ const requestId = this.pendingGetUser.allocate(resolve)
483
+ const outbound: GetUserMessage = {
484
+ type: "getUser",
485
+ requestId,
486
+ userId,
487
+ }
488
+ console.debug("[notion-custom-sdk] outbound postMessage", outbound)
489
+ window.parent.postMessage(outbound, "*")
490
+ })
491
+ }
492
+
493
+ listUsers(input: ListUsersInput = {}): Promise<ListUsersResult> {
494
+ return new Promise(resolve => {
495
+ const requestId = this.pendingListUsers.allocate(resolve)
496
+ const outbound: ListUsersMessage = {
497
+ type: "listUsers",
498
+ requestId,
499
+ startCursor: input.startCursor,
500
+ pageSize: input.pageSize,
501
+ }
502
+ console.debug("[notion-custom-sdk] outbound postMessage", outbound)
503
+ window.parent.postMessage(outbound, "*")
504
+ })
505
+ }
506
+
507
+ updatePage(input: UpdatePageInput): Promise<UpdatePageResult> {
508
+ return new Promise(resolve => {
509
+ if (
510
+ (input.properties === undefined ||
511
+ Object.keys(input.properties).length === 0) &&
512
+ input.icon === undefined &&
513
+ input.cover === undefined &&
514
+ input.archived === undefined
515
+ ) {
516
+ resolve({
517
+ status: "error",
518
+ error:
519
+ "updatePage requires at least one of: properties, icon, cover, archived.",
520
+ })
521
+ return
522
+ }
523
+
524
+ const requestId = this.pendingUpdatePage.allocate(resolve)
525
+ const outbound: UpdatePageMessage = {
526
+ type: "updatePage",
527
+ requestId,
528
+ pageId: input.pageId,
529
+ }
530
+ if (input.properties !== undefined) {
531
+ outbound.properties = input.properties
532
+ }
533
+ if (input.icon !== undefined) {
534
+ outbound.icon = input.icon
535
+ }
536
+ if (input.cover !== undefined) {
537
+ outbound.cover = input.cover
538
+ }
539
+ if (input.archived !== undefined) {
540
+ outbound.archived = input.archived
541
+ }
542
+ console.debug("[notion-custom-sdk] outbound postMessage", outbound)
543
+ window.parent.postMessage(outbound, "*")
544
+ })
545
+ }
546
+
547
+ /**
548
+ * Updates a page on a known data source, resolving any property keys against the data source's
549
+ * `propertyIdsByKey` before sending the bridge message. Used by the per-row `update` callback
550
+ * returned from {@link getDataSourceQueryView}.
551
+ */
552
+ updateDataSourcePage(args: {
553
+ dataSource: NotionDataSource
554
+ pageId: NotionPageId
555
+ input: NotionDataSourcePageUpdateInput
556
+ }): Promise<NotionDataSourcePageUpdateResult> {
557
+ const { dataSource, pageId, input } = args
558
+ const resolvedProperties =
559
+ input.properties === undefined
560
+ ? undefined
561
+ : resolvePropertyWriteMapForDataSource({
562
+ dataSource,
563
+ properties: input.properties,
564
+ operationName: "dataSourcePage.update",
565
+ })
566
+ if (resolvedProperties?.status === "error") {
567
+ return Promise.resolve(resolvedProperties)
568
+ }
569
+
570
+ return this.updatePage({
571
+ pageId,
572
+ properties: resolvedProperties?.properties,
573
+ icon: input.icon,
574
+ cover: input.cover,
575
+ archived: input.archived,
576
+ })
577
+ }
578
+
579
+ /**
580
+ * Translates the public `CreatePageInput["parent"]` into the bridge-native
581
+ * `CreatePageMessageParent`. The `data_source_key` variant is resolved sandbox-side against
582
+ * the data source mapping the host delivered in `init` / `dataSourcesChanged`.
583
+ */
584
+ private resolveCreatePageParent(parent: CreatePageInput["parent"]):
585
+ | {
586
+ status: "ok"
587
+ parent: CreatePageMessageParent
588
+ dataSource: NotionDataSource | undefined
589
+ }
590
+ | { status: "error"; error: string } {
591
+ switch (parent.type) {
592
+ case "page_id":
593
+ return { status: "ok", parent, dataSource: undefined }
594
+ case "data_source_id": {
595
+ const dataSource =
596
+ this.hostState.status === "initialized"
597
+ ? this.hostState.dataSources.find(
598
+ entry => entry.collectionPointer?.id === parent.data_source_id,
599
+ )
600
+ : undefined
601
+ return { status: "ok", parent, dataSource }
602
+ }
603
+ case "data_source_key": {
604
+ if (this.hostState.status !== "initialized") {
605
+ return {
606
+ status: "error",
607
+ error: `Cannot resolve data source key "${parent.key}" before the host has initialized the SDK.`,
608
+ }
609
+ }
610
+ const dataSource = this.hostState.dataSources.find(
611
+ entry => entry.key === parent.key,
612
+ )
613
+ if (dataSource === undefined) {
614
+ return {
615
+ status: "error",
616
+ error: `Unknown data source key "${parent.key}". Known keys: [${this.hostState.dataSources.map(entry => entry.key).join(", ")}].`,
617
+ }
618
+ }
619
+ if (dataSource.collectionPointer === undefined) {
620
+ return {
621
+ status: "error",
622
+ error: `Data source "${parent.key}" has not been mapped to a database yet.`,
623
+ }
624
+ }
625
+ return {
626
+ status: "ok",
627
+ parent: {
628
+ type: "data_source_id",
629
+ data_source_id: dataSource.collectionPointer.id,
630
+ },
631
+ dataSource,
632
+ }
633
+ }
634
+ default:
635
+ unreachable(parent)
636
+ }
637
+ }
638
+ }
639
+
640
+ function formatInvalidHostReason(
641
+ incomingType: string | undefined,
642
+ issues: readonly v.BaseIssue<unknown>[],
643
+ ): string {
644
+ const labelled = incomingType
645
+ ? `host message of type "${incomingType}"`
646
+ : "host message"
647
+ const first = issues[0]
648
+ if (!first) {
649
+ return `Could not parse ${labelled}: unknown error`
650
+ }
651
+ const path =
652
+ first.path
653
+ ?.map(p => String(p.key ?? ""))
654
+ .filter(Boolean)
655
+ .join(".") ?? ""
656
+ const detail = path ? `${path}: ${first.message}` : first.message
657
+ const extra = issues.length > 1 ? ` (+${issues.length - 1} more)` : ""
658
+ return `Could not parse ${labelled}: ${detail}${extra}`
659
+ }
@@ -0,0 +1,58 @@
1
+ import * as v from "valibot"
2
+ import type { NotionPageId } from "./pages/page"
3
+
4
+ /**
5
+ * Context about the custom block and its location in the block tree.
6
+ * Delivered as part of the `init` message and then re-sent via `contextChanged`
7
+ * whenever the host detects a change (e.g. the block moves into a different
8
+ * container or its enclosing page changes).
9
+ */
10
+ export const notionCustomBlockContextSchema = v.object({
11
+ /**
12
+ * ID of the custom block itself, the one hosting this sandboxed iframe.
13
+ */
14
+ customBlockId: v.string(),
15
+
16
+ /**
17
+ * The block that directly parents this custom block.
18
+ */
19
+ parent: v.object({
20
+ /**
21
+ * The ID of the block that directly parents this custom block.
22
+ */
23
+ id: v.string(),
24
+ /**
25
+ * The block type of the block that directly parents this custom block. This could be many
26
+ * different types, including: page, toggle, column, and callout.
27
+ */
28
+ type: v.string(),
29
+ }),
30
+
31
+ /**
32
+ * The nearest page ancestor to this custom block.
33
+ */
34
+ page: v.object({
35
+ /**
36
+ * The ID of the nearest page ancestor to this custom block.
37
+ */
38
+ id: v.string(),
39
+ }),
40
+ })
41
+
42
+ export type NotionCustomBlockContext = Omit<
43
+ v.InferOutput<typeof notionCustomBlockContextSchema>,
44
+ "page"
45
+ > & {
46
+ page: {
47
+ id: NotionPageId
48
+ }
49
+ }
50
+
51
+ export function parseNotionCustomBlockContext(
52
+ value: unknown,
53
+ ): NotionCustomBlockContext | undefined {
54
+ const parsed = v.safeParse(notionCustomBlockContextSchema, value)
55
+ return parsed.success
56
+ ? (parsed.output as NotionCustomBlockContext)
57
+ : undefined
58
+ }