@jackuait/blok 0.21.1 → 0.22.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.
package/dist/full.cjs CHANGED
@@ -1 +1 @@
1
- Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require(`./chunks/blok-DrMzzi_n.cjs`),t=require(`./chunks/constants-DGJO_Nr-.cjs`),n=require(`./chunks/objectSpread2-iNkE6aJh.cjs`),r=require(`./chunks/tools-sHA-2cBM.cjs`);var i={paragraph:{class:r.k,inlineToolbar:!0,config:{preserveBlank:!0}},header:{class:r.O,inlineToolbar:!0},list:{class:r.D,inlineToolbar:!0}},a=n.t(n.t({},i),{},{bold:{class:r.l},italic:{class:r.c},marker:{class:r.o},underline:{class:r.a},strikethrough:{class:r.i},link:{class:r.s}});exports.Blok=e.t,exports.Bold=r.l,exports.DATA_ATTR=t.ja,exports.Header=r.O,exports.Italic=r.c,exports.Link=r.s,exports.List=r.D,exports.Marker=r.o,exports.Paragraph=r.k,exports.Strikethrough=r.i,exports.Toggle=r.T,exports.Underline=r.a,exports.allTools=a,exports.defaultBlockTools=r.t,exports.defaultInlineTools=r.n,exports.defaultTools=i,exports.version=e.n;
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require(`./chunks/blok-Blnd71Ww.cjs`),t=require(`./chunks/constants-BIqDMhRj.cjs`),n=require(`./chunks/objectSpread2-iNkE6aJh.cjs`),r=require(`./chunks/tools-DTjC0C5o.cjs`);var i={paragraph:{class:r.k,inlineToolbar:!0,config:{preserveBlank:!0}},header:{class:r.O,inlineToolbar:!0},list:{class:r.D,inlineToolbar:!0}},a=n.t(n.t({},i),{},{bold:{class:r.l},italic:{class:r.c},marker:{class:r.o},underline:{class:r.a},strikethrough:{class:r.i},link:{class:r.s}});exports.Blok=e.t,exports.Bold=r.l,exports.DATA_ATTR=t.ja,exports.Header=r.O,exports.Italic=r.c,exports.Link=r.s,exports.List=r.D,exports.Marker=r.o,exports.Paragraph=r.k,exports.Strikethrough=r.i,exports.Toggle=r.T,exports.Underline=r.a,exports.allTools=a,exports.defaultBlockTools=r.t,exports.defaultInlineTools=r.n,exports.defaultTools=i,exports.version=e.n;
package/dist/full.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { n as e, t } from "./chunks/blok-DxI1HQZC.mjs";
2
- import { ja as n } from "./chunks/constants-0pfHI3Vc.mjs";
1
+ import { n as e, t } from "./chunks/blok-S6c68iVJ.mjs";
2
+ import { ja as n } from "./chunks/constants-BvxJIgSQ.mjs";
3
3
  import { t as r } from "./chunks/objectSpread2-jlBcdYeb.mjs";
4
- import { D as i, O as a, T as o, a as s, c, i as l, k as u, l as d, n as f, o as p, s as m, t as h } from "./chunks/tools-DqspF7V9.mjs";
4
+ import { D as i, O as a, T as o, a as s, c, i as l, k as u, l as d, n as f, o as p, s as m, t as h } from "./chunks/tools-B89FUeNz.mjs";
5
5
  //#region src/full.ts
6
6
  var g = {
7
7
  paragraph: {
package/dist/react.cjs CHANGED
@@ -1 +1 @@
1
- Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`}),require(`./chunks/chunk-BL1yB9zm.cjs`);const e=require(`./chunks/blok-DrMzzi_n.cjs`),t=require(`./chunks/objectSpread2-iNkE6aJh.cjs`);let n=require(`react`),r=require(`react/jsx-runtime`);var i=new WeakMap;function a(e,t){i.set(e,t)}function o(e){return i.get(e)}function s(e){i.delete(e)}function c(r,i){let[o,c]=(0,n.useState)(null),u=(0,n.useRef)(r);u.current=r;let d=(0,n.useRef)({editor:null,holder:null,destroyTimeout:null,isDestroyed:!1,depsToken:null}),f=(0,n.useMemo)(()=>({}),i==null?[]:i);(0,n.useEffect)(()=>{if(typeof window>`u`)return;let n=d.current;if(n.destroyTimeout!==null&&(clearTimeout(n.destroyTimeout),n.destroyTimeout=null),n.editor!==null&&!n.isDestroyed&&n.depsToken===f)return c(n.editor),()=>{l(n,c)};if(n.editor!==null&&!n.isDestroyed){s(n.editor);try{n.editor.destroy()}catch(e){}n.editor=null,n.holder=null,n.isDestroyed=!0,c(null)}let r=document.createElement(`div`);n.holder=r,n.isDestroyed=!1,n.depsToken=f;let i=u.current,o=new e.t(t.t(t.t({},i),{},{holder:r,onReady:()=>{var e,t;(e=(t=u.current).onReady)==null||e.call(t)},onChange:(...e)=>{var t,n;(t=(n=u.current).onChange)==null||t.call(n,...e)}}));return n.editor=o,a(o,r),o.isReady.then(()=>{n.editor===o&&!n.isDestroyed&&c(o)}).catch(()=>{if(n.editor===o&&!n.isDestroyed){s(o);try{o.destroy()}catch(e){}n.editor=null,n.holder=null,n.isDestroyed=!0,c(null)}}),()=>{l(n,c)}},[f]);let{readOnly:p}=r;(0,n.useEffect)(()=>{o!==null&&o.readOnly.set(p==null?!1:p)},[o,p]);let{autofocus:m}=r;return(0,n.useEffect)(()=>{o===null||!m||o.focus()},[o,m]),o}function l(e,t){e.destroyTimeout=setTimeout(()=>{if(e.editor!==null){s(e.editor);try{e.editor.destroy()}catch(e){}e.editor=null,e.holder=null,e.isDestroyed=!0,e.destroyTimeout=null,t(null)}},0)}var u=[`editor`],d=(0,n.forwardRef)(function(i,a){let{editor:s}=i,c=e.i(i,u),l=(0,n.useRef)(null);return(0,n.useEffect)(()=>{if(s===null||l.current===null)return;let e=o(s);if(e!==void 0)return l.current.appendChild(e),()=>{e.remove()}},[s]),(0,r.jsx)(`div`,t.t({ref:e=>{if(l.current=e,typeof a==`function`)a(e);else if(a!=null){let t=a;t.current=e}}},c))});exports.BlokContent=d,exports.useBlok=c;
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`}),require(`./chunks/chunk-BL1yB9zm.cjs`);const e=require(`./chunks/blok-Blnd71Ww.cjs`),t=require(`./chunks/objectSpread2-iNkE6aJh.cjs`);let n=require(`react`),r=require(`react/jsx-runtime`);var i=new WeakMap;function a(e,t){i.set(e,t)}function o(e){return i.get(e)}function s(e){i.delete(e)}function c(r,i){let[o,c]=(0,n.useState)(null),u=(0,n.useRef)(r);u.current=r;let d=(0,n.useRef)({editor:null,holder:null,destroyTimeout:null,isDestroyed:!1,depsToken:null}),f=(0,n.useMemo)(()=>({}),i==null?[]:i);(0,n.useEffect)(()=>{if(typeof window>`u`)return;let n=d.current;if(n.destroyTimeout!==null&&(clearTimeout(n.destroyTimeout),n.destroyTimeout=null),n.editor!==null&&!n.isDestroyed&&n.depsToken===f)return c(n.editor),()=>{l(n,c)};if(n.editor!==null&&!n.isDestroyed){s(n.editor);try{n.editor.destroy()}catch(e){}n.editor=null,n.holder=null,n.isDestroyed=!0,c(null)}let r=document.createElement(`div`);n.holder=r,n.isDestroyed=!1,n.depsToken=f;let i=u.current,o=new e.t(t.t(t.t({},i),{},{holder:r,onReady:()=>{var e,t;(e=(t=u.current).onReady)==null||e.call(t)},onChange:(...e)=>{var t,n;(t=(n=u.current).onChange)==null||t.call(n,...e)}}));return n.editor=o,a(o,r),o.isReady.then(()=>{n.editor===o&&!n.isDestroyed&&c(o)}).catch(()=>{if(n.editor===o&&!n.isDestroyed){s(o);try{o.destroy()}catch(e){}n.editor=null,n.holder=null,n.isDestroyed=!0,c(null)}}),()=>{l(n,c)}},[f]);let{readOnly:p}=r;(0,n.useEffect)(()=>{o!==null&&o.readOnly.set(p==null?!1:p)},[o,p]);let{autofocus:m}=r;return(0,n.useEffect)(()=>{o===null||!m||o.focus()},[o,m]),o}function l(e,t){e.destroyTimeout=setTimeout(()=>{if(e.editor!==null){s(e.editor);try{e.editor.destroy()}catch(e){}e.editor=null,e.holder=null,e.isDestroyed=!0,e.destroyTimeout=null,t(null)}},0)}var u=[`editor`],d=(0,n.forwardRef)(function(i,a){let{editor:s}=i,c=e.i(i,u),l=(0,n.useRef)(null);return(0,n.useEffect)(()=>{if(s===null||l.current===null)return;let e=o(s);if(e!==void 0)return l.current.appendChild(e),()=>{e.remove()}},[s]),(0,r.jsx)(`div`,t.t({ref:e=>{if(l.current=e,typeof a==`function`)a(e);else if(a!=null){let t=a;t.current=e}}},c))});exports.BlokContent=d,exports.useBlok=c;
package/dist/react.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { i as e, t } from "./chunks/blok-DxI1HQZC.mjs";
1
+ import { i as e, t } from "./chunks/blok-S6c68iVJ.mjs";
2
2
  import { t as n } from "./chunks/objectSpread2-jlBcdYeb.mjs";
3
3
  import { forwardRef as r, useEffect as i, useMemo as a, useRef as o, useState as s } from "react";
4
4
  import { jsx as c } from "react/jsx-runtime";
package/dist/tools.cjs CHANGED
@@ -1 +1 @@
1
- Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require(`./chunks/constants-DGJO_Nr-.cjs`),t=require(`./chunks/tools-sHA-2cBM.cjs`);exports.Audio=t.g,exports.Bold=t.l,exports.Bookmark=t.u,exports.Callout=t.w,exports.Code=t.y,exports.Column=t.p,exports.ColumnList=t.m,exports.Columns=t.f,exports.Convert=e.m,exports.Database=t.C,exports.DatabaseRow=t.S,exports.Divider=t.x,exports.Embed=t.d,exports.File=t._,exports.Header=t.O,exports.Image=t.v,exports.InlineCode=t.r,exports.Italic=t.c,exports.Link=t.s,exports.List=t.D,exports.Marker=t.o,exports.Paragraph=t.k,exports.Quote=t.b,exports.Strikethrough=t.i,exports.Table=t.E,exports.Toggle=t.T,exports.Underline=t.a,exports.Video=t.h,exports.defaultBlockTools=t.t,exports.defaultInlineTools=t.n;
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require(`./chunks/constants-BIqDMhRj.cjs`),t=require(`./chunks/tools-DTjC0C5o.cjs`);exports.Audio=t.g,exports.Bold=t.l,exports.Bookmark=t.u,exports.Callout=t.w,exports.Code=t.y,exports.Column=t.p,exports.ColumnList=t.m,exports.Columns=t.f,exports.Convert=e.m,exports.Database=t.C,exports.DatabaseRow=t.S,exports.Divider=t.x,exports.Embed=t.d,exports.File=t._,exports.Header=t.O,exports.Image=t.v,exports.InlineCode=t.r,exports.Italic=t.c,exports.Link=t.s,exports.List=t.D,exports.Marker=t.o,exports.Paragraph=t.k,exports.Quote=t.b,exports.Strikethrough=t.i,exports.Table=t.E,exports.Toggle=t.T,exports.Underline=t.a,exports.Video=t.h,exports.defaultBlockTools=t.t,exports.defaultInlineTools=t.n;
package/dist/tools.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { m as e } from "./chunks/constants-0pfHI3Vc.mjs";
2
- import { C as t, D as n, E as r, O as i, S as a, T as o, _ as s, a as c, b as l, c as u, d, f, g as p, h as m, i as h, k as g, l as _, m as v, n as y, o as b, p as x, r as S, s as C, t as w, u as T, v as E, w as D, x as O, y as k } from "./chunks/tools-DqspF7V9.mjs";
1
+ import { m as e } from "./chunks/constants-BvxJIgSQ.mjs";
2
+ import { C as t, D as n, E as r, O as i, S as a, T as o, _ as s, a as c, b as l, c as u, d, f, g as p, h as m, i as h, k as g, l as _, m as v, n as y, o as b, p as x, r as S, s as C, t as w, u as T, v as E, w as D, x as O, y as k } from "./chunks/tools-B89FUeNz.mjs";
3
3
  export { p as Audio, _ as Bold, T as Bookmark, D as Callout, k as Code, x as Column, v as ColumnList, f as Columns, e as Convert, t as Database, a as DatabaseRow, O as Divider, d as Embed, s as File, i as Header, E as Image, S as InlineCode, u as Italic, C as Link, n as List, b as Marker, g as Paragraph, l as Quote, h as Strikethrough, r as Table, o as Toggle, c as Underline, m as Video, w as defaultBlockTools, y as defaultInlineTools };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackuait/blok",
3
- "version": "0.21.1",
3
+ "version": "0.22.0",
4
4
  "description": "Blok — headless, highly extensible rich text editor built for developers who need to implement a block-based editing experience (similar to Notion) without building it from scratch",
5
5
  "module": "dist/blok.mjs",
6
6
  "main": "./dist/blok.cjs",
@@ -7,6 +7,7 @@ import { SAFE_STRUCTURAL_TAGS } from './constants';
7
7
  import { preprocessGoogleDocsHtml } from './google-docs-preprocessor';
8
8
  import { preprocessNotionHtml } from './notion-preprocessor';
9
9
  import { NOTION_BLOCKS_V3_MIME, parseNotionBlocksV3 } from './notion-blocks-v3';
10
+ import { NEXT_SPACE_MIMES, parseNextSpaceBlocks } from './next-space-blocks';
10
11
  import type { PasteHandler } from './handlers/base';
11
12
  import { BlokDataHandler } from './handlers/blok-data-handler';
12
13
  import { FilesHandler } from './handlers/files-handler';
@@ -114,12 +115,15 @@ export class Paste extends Module {
114
115
  const plainData = dataTransfer.getData('text/plain');
115
116
  const rawHtmlData = dataTransfer.getData('text/html');
116
117
 
117
- // Native Blok clipboard data always wins. When absent, fall back to
118
- // Notion's lossless proprietary flavour (web→web paste): it carries full
119
- // block state (checked, language, nesting, colour…) that the HTML flavour
120
- // cannot. We translate it into the Blok clipboard shape so it flows through
121
- // the existing BlokDataHandler two-pass hierarchy builder.
122
- const blokData = dataTransfer.getData(this.MIME_TYPE) || this.readNotionBlocksV3(dataTransfer);
118
+ // Native Blok clipboard data always wins. When absent, fall back to the
119
+ // lossless proprietary flavours of other editors (web→web paste): they
120
+ // carry full block state (checked, language, nesting, colour…) that the
121
+ // HTML flavour cannot. We translate them into the Blok clipboard shape so
122
+ // they flow through the existing BlokDataHandler two-pass hierarchy builder.
123
+ const blokData =
124
+ dataTransfer.getData(this.MIME_TYPE) ||
125
+ this.readNotionBlocksV3(dataTransfer) ||
126
+ this.readNextSpaceBlocks(dataTransfer);
123
127
 
124
128
  // Route to handlers based on data type
125
129
  const handled = await this.routeToHandlers(dataTransfer, plainData, rawHtmlData, blokData, pasteTarget);
@@ -153,6 +157,29 @@ export class Paste extends Module {
153
157
  return JSON.stringify(parsed);
154
158
  }
155
159
 
160
+ /**
161
+ * Read buildin.ai's `text/next-space-blocks` / `text/next-space-content`
162
+ * clipboard flavours and translate them into the Blok clipboard JSON shape,
163
+ * or return '' when absent / unparseable (so the caller falls back to HTML).
164
+ */
165
+ private readNextSpaceBlocks(dataTransfer: DataTransfer): string {
166
+ for (const mime of NEXT_SPACE_MIMES) {
167
+ const raw = dataTransfer.getData(mime);
168
+
169
+ if (!raw) {
170
+ continue;
171
+ }
172
+
173
+ const parsed = parseNextSpaceBlocks(raw);
174
+
175
+ if (parsed !== null && parsed.length > 0) {
176
+ return JSON.stringify(parsed);
177
+ }
178
+ }
179
+
180
+ return '';
181
+ }
182
+
156
183
  /**
157
184
  * Route paste data to handlers in priority order.
158
185
  */
@@ -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
+ }