@jackuait/blok 0.21.1 → 0.23.0

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.
@@ -0,0 +1,520 @@
1
+ /**
2
+ * Parser for buildin.ai's proprietary lossless clipboard flavours
3
+ * `text/next-space-blocks` (multi-block copy) and `text/next-space-content`
4
+ * (single-block copy).
5
+ *
6
+ * When you copy blocks in buildin.ai's web app, the clipboard carries — beside
7
+ * `text/plain` and `text/html` — a JSON envelope under these MIME types that
8
+ * preserves EVERY block's state (type, level, checked, language, icon, colour,
9
+ * nesting…). The HTML flavour cannot carry all of that, so this JSON is the
10
+ * high-fidelity migration source for buildin-web → Blok-web paste, mirroring
11
+ * what {@link parseNotionBlocksV3} does for Notion.
12
+ *
13
+ * Envelope: `{ blocks: [{ id, subTree: { <uuid>: node } }], pageId, fromType }`.
14
+ * The `blocks` array is ALREADY in document order; every top-level root points
15
+ * its `parentId` at `pageId` (itself absent from the payload, so roots have no
16
+ * emitted parent). `node.subNodes` is the ordered child-uuid array. A node's
17
+ * type is a NUMBER and its text is the concatenation of `data.segments[*].text`.
18
+ *
19
+ * The output is a flat array of {@link NextSpaceParsedBlock} (the same
20
+ * `{ id, tool, data, parentId }` shape the Blok clipboard handler consumes),
21
+ * which the two-pass `BlokDataHandler` builder turns into a nested block tree.
22
+ */
23
+
24
+ import { COLOR_PRESETS } from '../../shared/color-presets';
25
+ import { DEFAULT_EMOJI } from '../../../tools/callout/constants';
26
+ import { DEFAULT_LANGUAGE, LANGUAGES } from '../../../tools/code/constants';
27
+ import { matchEmbedService } from '../../../tools/link/registry';
28
+
29
+ // NOTE: notion-blocks-v3 also uses `colorVarName` for its inline <mark> colour
30
+ // styling. buildin's captured clipboard carries no inline colour marks (every
31
+ // enhancer is `{}`), so the plain-text mapping does not need it — wire it in
32
+ // alongside the TODO(inline-marks) work in `segmentsToHtml` once formatted
33
+ // content is captured. (Block-level callout colours ARE handled, via
34
+ // `normalizeBuildinColor` below.)
35
+
36
+ /** MIME flavours that carry buildin.ai's lossless block envelope. */
37
+ export const NEXT_SPACE_MIMES = ['text/next-space-blocks', 'text/next-space-content'];
38
+
39
+ /**
40
+ * A single Blok-ready block produced from the buildin envelope.
41
+ * Shape-compatible with the clipboard handler's `BlokClipboardBlock`.
42
+ */
43
+ export interface NextSpaceParsedBlock {
44
+ id: string;
45
+ tool: string;
46
+ data: Record<string, unknown>;
47
+ parentId?: string | null;
48
+ }
49
+
50
+ /** A buildin block node (loosely typed — most fields are optional). */
51
+ interface NextSpaceNode {
52
+ uuid: string;
53
+ type: number;
54
+ title?: string;
55
+ backgroundColor?: string;
56
+ textColor?: string;
57
+ data?: Record<string, unknown>;
58
+ subNodes?: string[];
59
+ [key: string]: unknown;
60
+ }
61
+
62
+ /** Result of mapping one node: tool + data. */
63
+ interface Mapped {
64
+ tool: string;
65
+ data: Record<string, unknown>;
66
+ }
67
+
68
+ /**
69
+ * Parse a `text/next-space-blocks` / `text/next-space-content` payload into a
70
+ * flat array of Blok-ready blocks. Returns `null` when the payload is not valid
71
+ * JSON or does not look like the buildin envelope (so callers fall back to the
72
+ * HTML path).
73
+ */
74
+ export function parseNextSpaceBlocks(json: string): NextSpaceParsedBlock[] | null {
75
+ const parsed = safeJsonParse(json);
76
+
77
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
78
+ return null;
79
+ }
80
+
81
+ const blocks = (parsed as { blocks?: unknown }).blocks;
82
+
83
+ if (!Array.isArray(blocks) || blocks.length === 0) {
84
+ return null;
85
+ }
86
+
87
+ // Merge every entry's `subTree` map into one uuid → node lookup, and remember
88
+ // the top-level (selected) order from the `blocks` array.
89
+ const byId = new Map<string, NextSpaceNode>();
90
+
91
+ blocks.forEach((entry) => {
92
+ const subTree = (entry as { subTree?: Record<string, NextSpaceNode> })?.subTree;
93
+
94
+ if (subTree === undefined || subTree === null || typeof subTree !== 'object') {
95
+ return;
96
+ }
97
+
98
+ Object.values(subTree).forEach((value) => {
99
+ if (value !== undefined && value !== null && typeof value === 'object' && typeof value.uuid === 'string') {
100
+ byId.set(value.uuid, value);
101
+ }
102
+ });
103
+ });
104
+
105
+ // Require envelope structure: at least one resolvable node.
106
+ if (byId.size === 0) {
107
+ return null;
108
+ }
109
+
110
+ const topLevelOrder = blocks
111
+ .map((entry) => (entry as { id?: string })?.id)
112
+ .filter((id): id is string => typeof id === 'string');
113
+
114
+ const result: NextSpaceParsedBlock[] = [];
115
+ const visited = new Set<string>();
116
+
117
+ /**
118
+ * Depth-first emit in document order. `parentId` is the uuid of the nearest
119
+ * EMITTED ancestor (null at the top).
120
+ */
121
+ const walk = (id: string, parentId: string | null): void => {
122
+ if (visited.has(id)) {
123
+ return;
124
+ }
125
+
126
+ const node = byId.get(id);
127
+
128
+ if (node === undefined) {
129
+ return;
130
+ }
131
+
132
+ visited.add(id);
133
+
134
+ // Tables expand into a grid block plus one paragraph block per cell, and
135
+ // consume their `type 28` row children — handle them before the leaf map.
136
+ if (node.type === 27) {
137
+ expandTable(node, parentId, byId, visited, result);
138
+
139
+ return;
140
+ }
141
+
142
+ const mapped = mapNode(node);
143
+
144
+ if (mapped === null) {
145
+ // Type-28 table rows are consumed by `expandTable`; a stray one is a no-op.
146
+ return;
147
+ }
148
+
149
+ const block: NextSpaceParsedBlock = { id, tool: mapped.tool, data: mapped.data };
150
+
151
+ if (parentId !== null) {
152
+ block.parentId = parentId;
153
+ }
154
+
155
+ result.push(block);
156
+
157
+ // Blok's callout keeps its body in CHILD blocks (CalloutData has no `text`
158
+ // field), so the callout's inline text — which buildin stores on the callout
159
+ // node itself — becomes a child paragraph, matching what a native callout
160
+ // copy carries. (Any `subNodes` are appended after it below.)
161
+ if (node.type === 13) {
162
+ const body = segmentsToHtml((node.data ?? {}).segments);
163
+
164
+ if (body.length > 0) {
165
+ result.push({ id: `${id}:callout-body`, tool: 'paragraph', data: { text: body }, parentId: id });
166
+ }
167
+ }
168
+
169
+ const children = Array.isArray(node.subNodes) ? node.subNodes : [];
170
+
171
+ children.forEach((childId) => walk(childId, id));
172
+ };
173
+
174
+ topLevelOrder.forEach((id) => walk(id, null));
175
+
176
+ return result;
177
+ }
178
+
179
+ /**
180
+ * Expand a buildin `table` (type 27) into a Blok table block (a grid of cell
181
+ * references) plus one paragraph block per cell — Blok stores cell content as
182
+ * child blocks referenced by id, not inline. The owning row nodes (type 28) are
183
+ * marked visited so the walker never emits them separately.
184
+ */
185
+ function expandTable(
186
+ table: NextSpaceNode,
187
+ parentId: string | null,
188
+ byId: Map<string, NextSpaceNode>,
189
+ visited: Set<string>,
190
+ result: NextSpaceParsedBlock[]
191
+ ): void {
192
+ const tableId = table.uuid;
193
+ const data = table.data ?? {};
194
+ const format = isPlainObject(data.format) ? data.format : {};
195
+ const rowIds = (Array.isArray(table.subNodes) ? table.subNodes : []).filter(
196
+ (rowId): rowId is string => typeof rowId === 'string'
197
+ );
198
+ const columnOrder = Array.isArray(format.tableBlockColumnOrder)
199
+ ? format.tableBlockColumnOrder.filter((columnId): columnId is string => typeof columnId === 'string')
200
+ : [];
201
+ const cells: NextSpaceParsedBlock[] = [];
202
+ const content: { blocks: string[] }[][] = rowIds.map((rowId) => {
203
+ visited.add(rowId);
204
+
205
+ const rowData = byId.get(rowId)?.data ?? {};
206
+ const collectionProperties = isPlainObject(rowData.collectionProperties) ? rowData.collectionProperties : {};
207
+
208
+ return columnOrder.map((columnId) => {
209
+ const cellId = `${rowId}:${columnId}`;
210
+
211
+ cells.push({ id: cellId, tool: 'paragraph', data: { text: segmentsToHtml(collectionProperties[columnId]) }, parentId: tableId });
212
+
213
+ return { blocks: [cellId] };
214
+ });
215
+ });
216
+
217
+ const tableBlock: NextSpaceParsedBlock = {
218
+ id: tableId,
219
+ tool: 'table',
220
+ data: {
221
+ withHeadings: format.tableBlockRowHeader === true,
222
+ withHeadingColumn: format.tableBlockColumnHeader === true,
223
+ content,
224
+ },
225
+ };
226
+
227
+ if (parentId !== null) {
228
+ tableBlock.parentId = parentId;
229
+ }
230
+
231
+ result.push(tableBlock);
232
+ cells.forEach((cell) => result.push(cell));
233
+ }
234
+
235
+ /** Map one buildin node to a Blok tool + data, or `null` to skip (table rows). */
236
+ function mapNode(node: NextSpaceNode): Mapped | null {
237
+ const data = node.data ?? {};
238
+ const text = segmentsToHtml(data.segments);
239
+
240
+ switch (node.type) {
241
+ case 1:
242
+ return { tool: 'paragraph', data: { text } };
243
+ case 3:
244
+ return { tool: 'list', data: { text, style: 'checklist', checked: data.checked === true } };
245
+ case 4:
246
+ return { tool: 'list', data: { text, style: 'unordered' } };
247
+ case 5:
248
+ return { tool: 'list', data: { text, style: 'ordered' } };
249
+ case 7:
250
+ return { tool: 'header', data: { text, level: data.level } };
251
+ case 38:
252
+ return { tool: 'header', data: { text, level: data.level, isToggleable: true, isOpen: true } };
253
+ case 6:
254
+ return { tool: 'toggle', data: { text, isOpen: true } };
255
+ case 9:
256
+ return { tool: 'divider', data: {} };
257
+ case 12:
258
+ return { tool: 'quote', data: { text } };
259
+ case 13:
260
+ return mapCallout(node, data);
261
+ case 25:
262
+ return {
263
+ tool: 'code',
264
+ data: { code: plainText(data.segments), language: mapLanguageName(languageOf(data.format)), lineNumbers: false },
265
+ };
266
+ case 23:
267
+ return { tool: 'code', data: { code: plainText(data.segments), language: 'latex', lineNumbers: false } };
268
+ case 10:
269
+ return { tool: 'column_list', data: {} };
270
+ case 11: {
271
+ const ratio = data.columnRatio;
272
+
273
+ return { tool: 'column', data: typeof ratio === 'number' && ratio !== 1 ? { widthRatio: ratio } : {} };
274
+ }
275
+ case 14:
276
+ return mapMedia(node, data, text);
277
+ case 21:
278
+ return mapEmbedOrBookmark(data, text);
279
+ case 28:
280
+ // Consumed by `expandTable`; a stray row should never surface as a block.
281
+ return null;
282
+ default:
283
+ // Unmapped types fall back to a paragraph carrying their text.
284
+ return { tool: 'paragraph', data: { text } };
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Map a buildin callout (type 13) to callout DATA only — colour names live on
290
+ * the node (not `data`). The body text is emitted SEPARATELY as a child
291
+ * paragraph by the walker (Blok callouts store their body as child blocks), so
292
+ * no `text` field is emitted here (the callout tool would silently discard it).
293
+ */
294
+ function mapCallout(node: NextSpaceNode, data: Record<string, unknown>): Mapped {
295
+ const icon = isPlainObject(data.icon) ? data.icon : {};
296
+ const emoji = typeof icon.value === 'string' && icon.value.length > 0 ? icon.value : DEFAULT_EMOJI;
297
+
298
+ return {
299
+ tool: 'callout',
300
+ data: { emoji, textColor: normalizeBuildinColor(node.textColor), backgroundColor: normalizeBuildinColor(node.backgroundColor) },
301
+ };
302
+ }
303
+
304
+ /** The set of colour preset names Blok recognises (its callout/marker palette). */
305
+ const BLOK_COLOR_NAMES = new Set(COLOR_PRESETS.map((preset) => preset.name));
306
+
307
+ /**
308
+ * Map a buildin colour NAME to a valid Blok preset name, or `null`. buildin uses
309
+ * British `grey`; Blok's preset is American `gray` — passing `grey` through
310
+ * verbatim yields an undefined `var(--blok-color-grey-bg)` that silently drops
311
+ * the colour (and the callout's border). Names outside Blok's palette collapse
312
+ * to `null` rather than render broken.
313
+ */
314
+ function normalizeBuildinColor(value: unknown): string | null {
315
+ if (typeof value !== 'string' || value.length === 0) {
316
+ return null;
317
+ }
318
+
319
+ const name = value.toLowerCase() === 'grey' ? 'gray' : value.toLowerCase();
320
+
321
+ return BLOK_COLOR_NAMES.has(name) ? name : null;
322
+ }
323
+
324
+ /** The `language` string from a code block's `data.format`, or `undefined`. */
325
+ function languageOf(format: unknown): unknown {
326
+ return isPlainObject(format) ? format.language : undefined;
327
+ }
328
+
329
+ /**
330
+ * Map a buildin media node (type 14) to image / video / audio / file by its
331
+ * `data.display` discriminator. The binary lives behind buildin's signed CDN —
332
+ * see {@link cdnUrl} — so the URL is best-effort.
333
+ */
334
+ function mapMedia(node: NextSpaceNode, data: Record<string, unknown>, text: string): Mapped {
335
+ const ossName = typeof data.ossName === 'string' ? data.ossName : '';
336
+ const title = typeof node.title === 'string' ? node.title : '';
337
+ const fileName = text.length > 0 ? text : title;
338
+
339
+ if (ossName.length === 0) {
340
+ // No CDN object — keep the filename as a paragraph rather than drop it.
341
+ return { tool: 'paragraph', data: { text: fileName } };
342
+ }
343
+
344
+ const url = cdnUrl(ossName);
345
+ const display = typeof data.display === 'string' ? data.display : 'file';
346
+
347
+ if (display === 'image') {
348
+ const imageData: Record<string, unknown> = { url };
349
+ const alignment = gravityAlignment(data.format);
350
+
351
+ if (alignment !== null) {
352
+ imageData.alignment = alignment;
353
+ }
354
+
355
+ return { tool: 'image', data: imageData };
356
+ }
357
+
358
+ if (display === 'video') {
359
+ return { tool: 'video', data: { url } };
360
+ }
361
+
362
+ if (display === 'audio') {
363
+ const audioData: Record<string, unknown> = { url };
364
+
365
+ if (fileName.length > 0) {
366
+ audioData.title = fileName;
367
+ audioData.fileName = fileName;
368
+ }
369
+
370
+ return { tool: 'audio', data: audioData };
371
+ }
372
+
373
+ // 'file' and any unknown display.
374
+ const fileData: Record<string, unknown> = { url };
375
+
376
+ if (fileName.length > 0) {
377
+ fileData.fileName = fileName;
378
+ }
379
+
380
+ return { tool: 'file', data: fileData };
381
+ }
382
+
383
+ /**
384
+ * Best-effort CDN URL for a buildin object. NOTE: the CDN signing token is NOT
385
+ * present in the clipboard JSON, so this unsigned URL may not load directly;
386
+ * faithful re-hosting of buildin media is a follow-up.
387
+ */
388
+ function cdnUrl(ossName: string): string {
389
+ return `https://cdn2.buildin.ai/${ossName}`;
390
+ }
391
+
392
+ /** Map a buildin `data.format.contentGravity` to a left/right image alignment. */
393
+ function gravityAlignment(format: unknown): 'left' | 'right' | null {
394
+ if (!isPlainObject(format)) {
395
+ return null;
396
+ }
397
+
398
+ if (format.contentGravity === 'LEFT') {
399
+ return 'left';
400
+ }
401
+
402
+ if (format.contentGravity === 'RIGHT') {
403
+ return 'right';
404
+ }
405
+
406
+ return null;
407
+ }
408
+
409
+ /**
410
+ * Map a buildin embed/bookmark node (type 21) to a resolved embed (provider
411
+ * match), a bookmark carrying the live URL, or a text fallback.
412
+ */
413
+ function mapEmbedOrBookmark(data: Record<string, unknown>, text: string): Mapped {
414
+ const url = firstHttpUrl(data.link);
415
+
416
+ if (url === null) {
417
+ return { tool: 'paragraph', data: { text } };
418
+ }
419
+
420
+ const embed = resolveEmbed(url);
421
+
422
+ if (embed !== null) {
423
+ return embed;
424
+ }
425
+
426
+ const bookmark: Record<string, unknown> = { url };
427
+ const title = plainText(data.linkInfo);
428
+
429
+ if (title.length > 0) {
430
+ bookmark.title = title;
431
+ }
432
+
433
+ return { tool: 'bookmark', data: bookmark };
434
+ }
435
+
436
+ /** Resolve a URL against the embed registry into Blok embed data, or `null`. */
437
+ function resolveEmbed(url: string): Mapped | null {
438
+ const match = matchEmbedService(url);
439
+
440
+ if (match === null) {
441
+ return null;
442
+ }
443
+
444
+ return { tool: 'embed', data: { service: match.service, source: url, embed: match.embedUrl, kind: match.kind } };
445
+ }
446
+
447
+ /** Pre-built lower-cased language-name → language-id lookup for code blocks. */
448
+ const LANGUAGE_BY_NAME = new Map(LANGUAGES.map((l) => [l.name.toLowerCase(), l.id]));
449
+
450
+ /** Map a buildin language name (e.g. "YAML") to a Blok language id. */
451
+ function mapLanguageName(name: unknown): string {
452
+ return LANGUAGE_BY_NAME.get(String(name).toLowerCase()) ?? DEFAULT_LANGUAGE;
453
+ }
454
+
455
+ /** Parse JSON, returning `null` instead of throwing on malformed input. */
456
+ function safeJsonParse(json: string): unknown {
457
+ try {
458
+ return JSON.parse(json);
459
+ } catch {
460
+ return null;
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Convert a buildin `segments` array into Blok inline HTML.
466
+ *
467
+ * The captured fixture has NO inline formatting — every `segment.enhancer` is
468
+ * `{}` — so this currently joins the HTML-escaped `text` of each segment, which
469
+ * is correct and lossless for plain text.
470
+ *
471
+ * EXTENSION POINT: inline marks (bold / italic / link / colour) live in
472
+ * `segment.enhancer`; to render them, branch here on the enhancer's keys and
473
+ * wrap the escaped text accordingly (mirroring notion-blocks-v3's `segmentHtml`).
474
+ * TODO(inline-marks): the enhancer key vocabulary is unknown until a FORMATTED
475
+ * buildin clipboard is captured — do NOT guess the key names.
476
+ */
477
+ function segmentsToHtml(segments: unknown): string {
478
+ if (!Array.isArray(segments)) {
479
+ return '';
480
+ }
481
+
482
+ return segments
483
+ .map((segment) => (isPlainObject(segment) && typeof segment.text === 'string' ? escapeHtml(segment.text) : ''))
484
+ .join('');
485
+ }
486
+
487
+ /** Concatenate a buildin `segments` array into raw plain text (no escaping). */
488
+ function plainText(segments: unknown): string {
489
+ if (!Array.isArray(segments)) {
490
+ return '';
491
+ }
492
+
493
+ return segments
494
+ .map((segment) => (isPlainObject(segment) && typeof segment.text === 'string' ? segment.text : ''))
495
+ .join('');
496
+ }
497
+
498
+ /** First candidate that resolves to an http(s) URL. */
499
+ function firstHttpUrl(...candidates: unknown[]): string | null {
500
+ for (const candidate of candidates) {
501
+ const raw = typeof candidate === 'string' ? candidate : plainText(candidate);
502
+ const trimmed = raw.trim();
503
+
504
+ if (/^https?:\/\//i.test(trimmed)) {
505
+ return trimmed;
506
+ }
507
+ }
508
+
509
+ return null;
510
+ }
511
+
512
+ /** Whether a value is a non-null, non-array object. */
513
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
514
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
515
+ }
516
+
517
+ /** Escape `&`, `<`, `>` for HTML text content. */
518
+ function escapeHtml(text: string): string {
519
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
520
+ }
@@ -20,7 +20,7 @@
20
20
  * correctly-nested block tree.
21
21
  */
22
22
 
23
- import { colorVarName } from '../../shared/color-presets';
23
+ import { COLOR_PRESETS, colorVarName } from '../../shared/color-presets';
24
24
  import { DEFAULT_EMOJI } from '../../../tools/callout/constants';
25
25
  import { DEFAULT_LANGUAGE, LANGUAGES } from '../../../tools/code/constants';
26
26
  import { matchEmbedService } from '../../../tools/link/registry';
@@ -151,6 +151,17 @@ export function parseNotionBlocksV3(json: string): NotionParsedBlock[] | null {
151
151
 
152
152
  result.push(block);
153
153
 
154
+ // Blok's callout keeps its body in CHILD blocks, so the callout's own inline
155
+ // title line becomes a child paragraph (emitted before its `content`
156
+ // children), matching what a native callout copy carries.
157
+ if (value.type === 'callout') {
158
+ const calloutTitle = richText(value.properties?.title, byId);
159
+
160
+ if (calloutTitle.length > 0) {
161
+ result.push({ id: `${id}:callout-body`, tool: 'paragraph', data: { text: calloutTitle }, parentId: id });
162
+ }
163
+ }
164
+
154
165
  children.forEach((childId) => walk(childId, id));
155
166
  };
156
167
 
@@ -328,7 +339,10 @@ function mapValue(value: NotionValue, byId: Map<string, NotionValue>): Mapped |
328
339
  const emoji = typeof format.page_icon === 'string' && format.page_icon.length > 0 ? format.page_icon : DEFAULT_EMOJI;
329
340
  const { textColor, backgroundColor } = parseBlockColor(format.block_color);
330
341
 
331
- return { tool: 'callout', data: { emoji, text, textColor, backgroundColor } };
342
+ // No inline `text`: Blok's callout stores its body in CHILD blocks, so the
343
+ // callout's title line is emitted as a child paragraph by `walk` (and a
344
+ // stray `data.text` would be silently discarded by the callout tool).
345
+ return { tool: 'callout', data: { emoji, textColor, backgroundColor } };
332
346
  }
333
347
  case 'page':
334
348
  // A `page` block inside content is a SUB-PAGE reference: its body lives on
@@ -862,11 +876,21 @@ function buildColourStyle(colourFlags: unknown[]): string {
862
876
  return parts.join('; ');
863
877
  }
864
878
 
865
- /** Map a Notion `block_color` to callout `{ textColor, backgroundColor }` names. */
879
+ /** The set of colour preset names Blok recognises (its callout/marker palette). */
880
+ const BLOK_COLOR_NAMES = new Set(COLOR_PRESETS.map((preset) => preset.name));
881
+
882
+ /**
883
+ * Map a Notion `block_color` to callout `{ textColor, backgroundColor }` names.
884
+ * Names outside Blok's preset palette collapse to `null`: the callout tool
885
+ * builds `var(--blok-color-<name>-…)` from the name verbatim, so an unrecognised
886
+ * name yields an undefined custom property that silently drops the colour AND
887
+ * the callout's border. Notion's palette already aligns with Blok's, so this is
888
+ * a safety clamp rather than a remap.
889
+ */
866
890
  function parseBlockColor(token: unknown): { textColor: string | null; backgroundColor: string | null } {
867
891
  const colour = parseNotionColour(token);
868
892
 
869
- if (colour === null) {
893
+ if (colour === null || !BLOK_COLOR_NAMES.has(colour.name)) {
870
894
  return { textColor: null, backgroundColor: null };
871
895
  }
872
896
 
@@ -0,0 +1,63 @@
1
+ import { forwardRef, useImperativeHandle, useEffect, useRef, type DependencyList } from 'react';
2
+ import { useBlok } from './useBlok';
3
+ import { BlokContent } from './BlokContent';
4
+ import type { Blok } from '@/types';
5
+ import type { UseBlokConfig } from './types';
6
+
7
+ /**
8
+ * Props for the all-in-one BlokEditor component.
9
+ * Accepts every useBlok config prop (except `onReady`, re-typed below to receive
10
+ * the ready instance), plus container `className`/`data-testid` and an optional
11
+ * `deps` list. When any value in `deps` changes, the editor is destroyed and
12
+ * recreated (use this when `tools` or other structural config changes).
13
+ *
14
+ * `data` is uncontrolled (seed-only): it sets the INITIAL content. After mount the
15
+ * editor owns the document — passing a new `data` reference does NOT reload it.
16
+ * Read content via `onChange` or the ref (`ref.current.save()`); replace it
17
+ * imperatively via `ref.current.render(newData)`.
18
+ */
19
+ export interface BlokEditorProps extends Omit<UseBlokConfig, 'onReady'> {
20
+ /** When any value changes, the editor is destroyed and recreated. */
21
+ deps?: DependencyList;
22
+ /** Class name applied to the editor container element. */
23
+ className?: string;
24
+ /** Test id forwarded to the editor container element (via data-testid). */
25
+ 'data-testid'?: string;
26
+ /**
27
+ * Called once the editor is ready, with the live Blok instance. Fires after the
28
+ * forwarded ref is committed, so `ref.current` is also populated at this point.
29
+ */
30
+ onReady?: (editor: Blok) => void;
31
+ }
32
+
33
+ /**
34
+ * The recommended way to embed Blok in React. Internally wires `useBlok` and
35
+ * `BlokContent`, and forwards a ref to the live `Blok` instance.
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * const ref = useRef<Blok | null>(null);
40
+ * <BlokEditor ref={ref} tools={tools} data={data} theme={theme} onReady={(e) => e.focus()} />;
41
+ * ```
42
+ */
43
+ export const BlokEditor = forwardRef<Blok | null, BlokEditorProps>(
44
+ function BlokEditor({ deps, className, onReady, 'data-testid': dataTestId, ...config }, ref) {
45
+ const editor = useBlok(config, deps);
46
+
47
+ useImperativeHandle<Blok | null, Blok | null>(ref, () => editor, [editor]);
48
+
49
+ // Own onReady here instead of forwarding it to the core config: the core
50
+ // onReady fires before useImperativeHandle commits the ref. This passive
51
+ // effect runs AFTER the commit, so both the instance argument and ref.current
52
+ // are reliably populated.
53
+ const onReadyRef = useRef(onReady);
54
+ onReadyRef.current = onReady;
55
+ useEffect(() => {
56
+ if (editor !== null) {
57
+ onReadyRef.current?.(editor);
58
+ }
59
+ }, [editor]);
60
+
61
+ return <BlokContent editor={editor} className={className} data-testid={dataTestId} />;
62
+ }
63
+ );
@@ -1,3 +1,5 @@
1
1
  export { useBlok } from './useBlok';
2
2
  export { BlokContent } from './BlokContent';
3
+ export { BlokEditor } from './BlokEditor';
3
4
  export type { UseBlokConfig, BlokContentProps } from './types';
5
+ export type { BlokEditorProps } from './BlokEditor';
@@ -1,10 +1,20 @@
1
- import type { BlokConfig, Blok } from '@/types';
1
+ import type { BlokConfig, Blok, EditorWidth } from '@/types';
2
2
 
3
3
  /**
4
4
  * Configuration for useBlok hook.
5
- * Same as BlokConfig but without `holder` — the holder is managed by BlokContent.
5
+ * Same as BlokConfig but without `holder` (managed by BlokContent), plus a
6
+ * React-only reactive `width` prop.
7
+ *
8
+ * Reactive props (sync after mount without recreation):
9
+ * - `readOnly` — calls `editor.readOnly.set(value)`
10
+ * - `autofocus` — calls `editor.focus()` when changed to true
11
+ * - `theme` — calls `editor.theme.set(value)`
12
+ * - `width` — calls `editor.width.set(value)`
6
13
  */
7
- export interface UseBlokConfig extends Omit<BlokConfig, 'holder'> {}
14
+ export interface UseBlokConfig extends Omit<BlokConfig, 'holder'> {
15
+ /** Editor content width mode. Synced reactively after mount via `editor.width.set()`. */
16
+ width?: EditorWidth;
17
+ }
8
18
 
9
19
  /**
10
20
  * Props for the BlokContent component.