@jackuait/blok 0.10.0-beta.9 → 0.10.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/blok.mjs +2 -2
- package/dist/chunks/{blok-DDu252IK.mjs → blok-BfcBwAfE.mjs} +1211 -1159
- package/dist/chunks/{constants-DMW9a31I.mjs → constants-QNVyXALL.mjs} +49 -48
- package/dist/chunks/{tools-XmzH2rgQ.mjs → tools-DHtzbrxy.mjs} +1403 -1220
- package/dist/full.mjs +3 -3
- package/dist/react.mjs +2 -2
- package/dist/tools.mjs +2 -2
- package/package.json +3 -5
- package/src/cli/commands/convert-gdocs/index.ts +26 -0
- package/src/cli/commands/convert-html/block-builder.ts +392 -0
- package/src/cli/commands/convert-html/id-generator.ts +11 -0
- package/src/cli/commands/convert-html/index.ts +23 -0
- package/src/cli/commands/convert-html/preprocessor.ts +422 -0
- package/src/cli/commands/convert-html/sanitizer.ts +93 -0
- package/src/cli/commands/convert-html/types.ts +15 -0
- package/src/cli/index.ts +56 -5
- package/src/components/block/index.ts +44 -10
- package/src/components/constants/data-attributes.ts +10 -0
- package/src/components/icons/index.ts +16 -0
- package/src/components/modules/blockEvents/composers/keyboardNavigation.ts +18 -0
- package/src/components/modules/blockManager/hierarchy.ts +4 -1
- package/src/components/modules/readonly.ts +46 -0
- package/src/components/modules/rectangleSelection.ts +25 -5
- package/src/components/modules/toolbar/index.ts +96 -19
- package/src/components/modules/toolbar/styles.ts +0 -2
- package/src/components/modules/uiControllers/controllers/blockHover.ts +44 -1
- package/src/components/tools/block.ts +10 -0
- package/src/components/utils/placeholder.ts +9 -2
- package/src/styles/main.css +16 -0
- package/src/tools/callout/constants.ts +2 -1
- package/src/tools/callout/dom-builder.ts +13 -1
- package/src/tools/callout/index.ts +21 -7
- package/src/tools/code/constants.ts +9 -1
- package/src/tools/code/dom-builder.ts +90 -54
- package/src/tools/code/index.ts +73 -31
- package/src/tools/divider/index.ts +5 -0
- package/src/tools/header/index.ts +47 -1
- package/src/tools/list/dom-builder.ts +3 -1
- package/src/tools/list/index.ts +55 -3
- package/src/tools/list/list-helpers.ts +2 -2
- package/src/tools/nested-blocks.ts +25 -0
- package/src/tools/paragraph/index.ts +47 -6
- package/src/tools/quote/index.ts +43 -8
- package/src/tools/stub/index.ts +10 -0
- package/src/tools/table/index.ts +238 -6
- package/src/tools/table/table-add-controls.ts +37 -5
- package/src/tools/table/table-cell-blocks.ts +57 -18
- package/src/tools/table/table-core.ts +2 -0
- package/src/tools/table/table-corner-drag.ts +247 -0
- package/src/tools/table/table-operations.ts +41 -14
- package/src/tools/toggle/dom-builder.ts +1 -0
- package/src/tools/toggle/index.ts +25 -0
- package/src/tools/toggle/toggle-lifecycle.ts +5 -4
- package/src/types-internal/jsdom.d.ts +9 -0
- package/types/tools/adapters/block-tool-adapter.d.ts +6 -0
- package/types/tools/block-tool.d.ts +10 -0
- package/bin/blok.mjs +0 -10
- package/dist/cli.mjs +0 -37
- package/src/tools/code/language-picker.ts +0 -241
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preprocess old knowledgebase HTML before block conversion.
|
|
3
|
+
*
|
|
4
|
+
* Fixes issues from the old KB's Summernote editor:
|
|
5
|
+
* 1. `<div style="background: rgb(...)">` callout-like blocks → `<aside>`
|
|
6
|
+
* 2. White/transparent `background-color` on inline elements → stripped
|
|
7
|
+
* 3. Multiple `<p>` inside table cells → `<br>`-separated content
|
|
8
|
+
* 4. `<p> </p>` visual spacers → removed
|
|
9
|
+
* 5. `<del>`/`<strike>` → `<s>` (Blok only recognises `<s>`)
|
|
10
|
+
* 6. `<p>• text</p>` pseudo-lists → `<ul><li>text</li></ul>`
|
|
11
|
+
*
|
|
12
|
+
* Adapted from the knowledgebase frontend's preprocessKnowledgebaseHtml
|
|
13
|
+
* for use with jsdom's DOM API in a Node.js CLI tool.
|
|
14
|
+
*/
|
|
15
|
+
export function preprocess(wrapper: HTMLElement): void {
|
|
16
|
+
convertBackgroundDivsToCallouts(wrapper);
|
|
17
|
+
stripSpuriousBackgroundColors(wrapper);
|
|
18
|
+
convertTableCellParagraphs(wrapper);
|
|
19
|
+
stripNbspOnlyParagraphs(wrapper);
|
|
20
|
+
convertStrikethroughTags(wrapper);
|
|
21
|
+
convertBulletParagraphsToLists(wrapper);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Convert block-level `<div>` elements with background colors to `<aside>`.
|
|
26
|
+
*
|
|
27
|
+
* Skips divs inside tables and those with white/transparent backgrounds.
|
|
28
|
+
* Unwraps non-semantic inner `<div>` wrappers and strips trailing `<br>`
|
|
29
|
+
* inside paragraphs.
|
|
30
|
+
*/
|
|
31
|
+
function convertBackgroundDivsToCallouts(wrapper: HTMLElement): void {
|
|
32
|
+
const doc = wrapper.ownerDocument;
|
|
33
|
+
|
|
34
|
+
for (const div of Array.from(wrapper.querySelectorAll<HTMLElement>('div[style]'))) {
|
|
35
|
+
if (div.closest('table')) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const bgColor = getBackgroundColor(div);
|
|
40
|
+
|
|
41
|
+
if (!bgColor || isSpuriousBackgroundColor(bgColor)) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const aside = doc.createElement('aside');
|
|
46
|
+
|
|
47
|
+
aside.style.backgroundColor = bgColor;
|
|
48
|
+
aside.append(...Array.from(div.childNodes));
|
|
49
|
+
|
|
50
|
+
// Unwrap non-semantic <div> wrappers so the aside's direct children are
|
|
51
|
+
// the content elements (<p>, <a>, etc.), not intermediate <div> shells.
|
|
52
|
+
unwrapBareDivs(aside);
|
|
53
|
+
|
|
54
|
+
// Strip trailing <br> inside paragraphs — the paste handler splits
|
|
55
|
+
// content at <br> boundaries, so a trailing one creates an empty block.
|
|
56
|
+
stripTrailingBrInParagraphs(aside);
|
|
57
|
+
|
|
58
|
+
div.replaceWith(aside);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Repeatedly unwrap non-semantic `<div>` wrappers (no style or class) that are
|
|
64
|
+
* direct children of the given element, replacing them with their child nodes.
|
|
65
|
+
*/
|
|
66
|
+
function unwrapBareDivs(parent: HTMLElement): void {
|
|
67
|
+
for (;;) {
|
|
68
|
+
const bareDivs = Array.from(parent.querySelectorAll<HTMLElement>(':scope > div'))
|
|
69
|
+
.filter((d) => !d.getAttribute('style') && !d.getAttribute('class'));
|
|
70
|
+
|
|
71
|
+
if (bareDivs.length === 0) {
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const child of bareDivs) {
|
|
76
|
+
child.replaceWith(...Array.from(child.childNodes));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Remove trailing `<br>` elements from paragraphs inside the given element.
|
|
83
|
+
*/
|
|
84
|
+
function stripTrailingBrInParagraphs(parent: HTMLElement): void {
|
|
85
|
+
for (const p of Array.from(parent.querySelectorAll('p'))) {
|
|
86
|
+
const lastChild = p.lastElementChild;
|
|
87
|
+
|
|
88
|
+
if (lastChild?.tagName === 'BR') {
|
|
89
|
+
lastChild.remove();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Minimum channel brightness for a colour to be considered "near-white".
|
|
96
|
+
*
|
|
97
|
+
* Any `rgb(r, g, b)` where all three channels are >= this value is stripped.
|
|
98
|
+
*/
|
|
99
|
+
const NEAR_WHITE_MIN_CHANNEL = 250;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Remove white/transparent `background-color` from inline elements.
|
|
103
|
+
*
|
|
104
|
+
* After stripping the property, empty wrapper elements (no remaining styles
|
|
105
|
+
* and no text) are unwrapped.
|
|
106
|
+
*/
|
|
107
|
+
function stripSpuriousBackgroundColors(wrapper: HTMLElement): void {
|
|
108
|
+
const candidates = wrapper.querySelectorAll<HTMLElement>('[style*="background-color"]');
|
|
109
|
+
|
|
110
|
+
for (const el of Array.from(candidates)) {
|
|
111
|
+
if (!isSpuriousBackgroundColor(el.style.backgroundColor)) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
el.style.removeProperty('background-color');
|
|
116
|
+
|
|
117
|
+
if (el.getAttribute('style')?.trim() === '') {
|
|
118
|
+
el.removeAttribute('style');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (isEmptyWrapper(el)) {
|
|
122
|
+
el.replaceWith(...Array.from(el.childNodes));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Check whether a computed `background-color` value is visually invisible
|
|
129
|
+
* (white, near-white, or transparent).
|
|
130
|
+
*/
|
|
131
|
+
function isSpuriousBackgroundColor(value: string): boolean {
|
|
132
|
+
if (!value) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const normalised = value.replace(/\s/g, '').toLowerCase();
|
|
137
|
+
|
|
138
|
+
if (normalised === 'transparent') {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const rgbaMatch = normalised.match(/^rgba?\((\d+),(\d+),(\d+)(?:,([^)]+))?\)$/);
|
|
143
|
+
|
|
144
|
+
if (!rgbaMatch) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const alpha = rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1;
|
|
149
|
+
|
|
150
|
+
if (alpha === 0) {
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const r = parseInt(rgbaMatch[1], 10);
|
|
155
|
+
const g = parseInt(rgbaMatch[2], 10);
|
|
156
|
+
const b = parseInt(rgbaMatch[3], 10);
|
|
157
|
+
|
|
158
|
+
return r >= NEAR_WHITE_MIN_CHANNEL && g >= NEAR_WHITE_MIN_CHANNEL && b >= NEAR_WHITE_MIN_CHANNEL;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Check whether an element is a pure wrapper with no semantic value
|
|
163
|
+
* (no attributes and no text content, or just whitespace).
|
|
164
|
+
*/
|
|
165
|
+
function isEmptyWrapper(el: HTMLElement): boolean {
|
|
166
|
+
if (el.attributes.length > 0) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const text = (el.textContent ?? '').replace(/[\s\u00A0]/g, '');
|
|
171
|
+
|
|
172
|
+
return text.length === 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Extract `background-color` from an element's style attribute.
|
|
177
|
+
*
|
|
178
|
+
* Handles both `background-color:` and `background:` (shorthand) properties.
|
|
179
|
+
* Uses the element's computed style property first, falling back to manual
|
|
180
|
+
* parsing of the style attribute for shorthand notation.
|
|
181
|
+
*/
|
|
182
|
+
function getBackgroundColor(el: HTMLElement): string {
|
|
183
|
+
// Try the direct property first
|
|
184
|
+
if (el.style.backgroundColor) {
|
|
185
|
+
return el.style.backgroundColor;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Fall back to parsing the style attribute for shorthand `background:`
|
|
189
|
+
const styleAttr = el.getAttribute('style') ?? '';
|
|
190
|
+
const match = styleAttr.match(/background:\s*([^;]+)/i);
|
|
191
|
+
|
|
192
|
+
if (match) {
|
|
193
|
+
const value = match[1].trim();
|
|
194
|
+
// Only return color values, not url() or other background sub-properties
|
|
195
|
+
const colorMatch = value.match(/^(rgb[a]?\([^)]+\)|#[0-9a-fA-F]{3,8}|[a-z]+)$/i);
|
|
196
|
+
|
|
197
|
+
if (colorMatch) {
|
|
198
|
+
return colorMatch[1];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return '';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Convert `<p>` boundaries to `<br>` line breaks inside table cells.
|
|
207
|
+
*
|
|
208
|
+
* Only targets `<td>` and `<th>` — top-level `<p>` tags are left intact.
|
|
209
|
+
*/
|
|
210
|
+
function convertTableCellParagraphs(wrapper: HTMLElement): void {
|
|
211
|
+
for (const cell of Array.from(wrapper.querySelectorAll('td, th'))) {
|
|
212
|
+
const paragraphs = cell.querySelectorAll('p');
|
|
213
|
+
|
|
214
|
+
if (paragraphs.length === 0) {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const p of Array.from(paragraphs)) {
|
|
219
|
+
replaceParagraphWithBr(p);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
stripTrailingBreaks(cell);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Replace a `<p>` element with its child nodes followed by a `<br>`,
|
|
228
|
+
* or remove it entirely if it is empty / nbsp-only.
|
|
229
|
+
*/
|
|
230
|
+
function replaceParagraphWithBr(p: HTMLParagraphElement): void {
|
|
231
|
+
if (p.innerHTML.trim() === '' || p.innerHTML.trim() === ' ') {
|
|
232
|
+
p.remove();
|
|
233
|
+
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const doc = p.ownerDocument;
|
|
238
|
+
const fragment = doc.createDocumentFragment();
|
|
239
|
+
|
|
240
|
+
fragment.append(...Array.from(p.childNodes));
|
|
241
|
+
fragment.append(doc.createElement('br'));
|
|
242
|
+
p.replaceWith(fragment);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Remove paragraphs whose only content is non-breaking spaces or whitespace.
|
|
247
|
+
*/
|
|
248
|
+
function stripNbspOnlyParagraphs(wrapper: HTMLElement): void {
|
|
249
|
+
for (const p of Array.from(wrapper.querySelectorAll('p'))) {
|
|
250
|
+
// Skip paragraphs inside table cells — those are handled by convertTableCellParagraphs
|
|
251
|
+
if (p.closest('td') || p.closest('th')) {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const textContent = p.textContent ?? '';
|
|
256
|
+
const stripped = textContent.replace(/[\s\u00A0]/g, '');
|
|
257
|
+
|
|
258
|
+
if (stripped.length === 0) {
|
|
259
|
+
p.remove();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Convert `<del>` and `<strike>` elements to `<s>`.
|
|
266
|
+
*/
|
|
267
|
+
function convertStrikethroughTags(wrapper: HTMLElement): void {
|
|
268
|
+
const doc = wrapper.ownerDocument;
|
|
269
|
+
|
|
270
|
+
for (const el of Array.from(wrapper.querySelectorAll('del, strike'))) {
|
|
271
|
+
const replacement = doc.createElement('s');
|
|
272
|
+
|
|
273
|
+
replacement.append(...Array.from(el.childNodes));
|
|
274
|
+
el.replaceWith(replacement);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Bullet characters that indicate a pseudo-list paragraph.
|
|
280
|
+
*
|
|
281
|
+
* Matches: `\u2022` (bullet), `\u00B7` (middle dot), or `- ` (hyphen + space).
|
|
282
|
+
*/
|
|
283
|
+
const BULLET_PREFIX = /^[\u2022\u00B7][\s\u00A0]*|^-\s/;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Convert `<p>• text</p>` pseudo-lists into proper `<ul><li>` markup.
|
|
287
|
+
*
|
|
288
|
+
* Groups consecutive bullet paragraphs into a single `<ul>` and strips
|
|
289
|
+
* the bullet prefix. Only processes direct children of the wrapper.
|
|
290
|
+
*/
|
|
291
|
+
function convertBulletParagraphsToLists(wrapper: HTMLElement): void {
|
|
292
|
+
const doc = wrapper.ownerDocument;
|
|
293
|
+
|
|
294
|
+
// Collect runs of consecutive bullet paragraphs. Each run is a group
|
|
295
|
+
// that will become a single <ul>.
|
|
296
|
+
const groups = collectBulletGroups(wrapper);
|
|
297
|
+
|
|
298
|
+
for (const group of groups) {
|
|
299
|
+
const ul = doc.createElement('ul');
|
|
300
|
+
|
|
301
|
+
group[0].before(ul);
|
|
302
|
+
|
|
303
|
+
for (const p of group) {
|
|
304
|
+
convertBulletParagraphToListItem(p, ul);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Strip the bullet prefix from a paragraph and append its content as
|
|
311
|
+
* a `<li>` to the given list.
|
|
312
|
+
*/
|
|
313
|
+
function convertBulletParagraphToListItem(p: HTMLParagraphElement, ul: HTMLUListElement): void {
|
|
314
|
+
const li = p.ownerDocument.createElement('li');
|
|
315
|
+
|
|
316
|
+
// Strip the bullet character and any leading nbsp/whitespace from
|
|
317
|
+
// the first text node, preserving any inline HTML that follows.
|
|
318
|
+
const firstTextNode = findFirstTextNode(p);
|
|
319
|
+
|
|
320
|
+
if (firstTextNode) {
|
|
321
|
+
firstTextNode.textContent = (firstTextNode.textContent ?? '').replace(BULLET_PREFIX, '');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
li.append(...Array.from(p.childNodes));
|
|
325
|
+
ul.appendChild(li);
|
|
326
|
+
p.remove();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Walk direct children of an element and return groups of consecutive `<p>`
|
|
331
|
+
* elements whose text starts with a bullet character.
|
|
332
|
+
*/
|
|
333
|
+
function collectBulletGroups(wrapper: HTMLElement): HTMLParagraphElement[][] {
|
|
334
|
+
const groups: HTMLParagraphElement[][] = [];
|
|
335
|
+
const children = Array.from(wrapper.childNodes);
|
|
336
|
+
|
|
337
|
+
for (const child of children) {
|
|
338
|
+
if (child.nodeType !== Node.ELEMENT_NODE) {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const el = child as HTMLElement;
|
|
343
|
+
const isBulletParagraph = el.tagName === 'P' && BULLET_PREFIX.test(el.textContent ?? '');
|
|
344
|
+
|
|
345
|
+
if (!isBulletParagraph) {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const lastGroup = groups[groups.length - 1];
|
|
350
|
+
const previousSibling = findPreviousElementSibling(el);
|
|
351
|
+
const belongsToCurrentGroup = lastGroup
|
|
352
|
+
&& previousSibling !== null
|
|
353
|
+
&& lastGroup[lastGroup.length - 1] === previousSibling;
|
|
354
|
+
|
|
355
|
+
if (belongsToCurrentGroup) {
|
|
356
|
+
lastGroup.push(el as HTMLParagraphElement);
|
|
357
|
+
} else {
|
|
358
|
+
groups.push([el as HTMLParagraphElement]);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return groups;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Find the previous sibling that is an element, skipping non-element nodes.
|
|
367
|
+
*/
|
|
368
|
+
function findPreviousElementSibling(el: HTMLElement): Element | null {
|
|
369
|
+
const prev = el.previousSibling;
|
|
370
|
+
|
|
371
|
+
if (!prev) {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (prev.nodeType === Node.ELEMENT_NODE) {
|
|
376
|
+
return prev as Element;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Remove trailing `<br>` elements and whitespace-only text nodes from the end
|
|
384
|
+
* of an element.
|
|
385
|
+
*/
|
|
386
|
+
function stripTrailingBreaks(element: Element): void {
|
|
387
|
+
for (;;) {
|
|
388
|
+
const node = element.lastChild;
|
|
389
|
+
|
|
390
|
+
if (!node) {
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const isBr = node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName === 'BR';
|
|
395
|
+
const isBlankText = node.nodeType === Node.TEXT_NODE && node.textContent?.trim() === '';
|
|
396
|
+
|
|
397
|
+
if (!isBr && !isBlankText) {
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
node.remove();
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Walk the DOM tree depth-first to find the first Text node.
|
|
407
|
+
*/
|
|
408
|
+
function findFirstTextNode(node: Node): Text | null {
|
|
409
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
410
|
+
return node as Text;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
for (const child of Array.from(node.childNodes)) {
|
|
414
|
+
const found = findFirstTextNode(child);
|
|
415
|
+
|
|
416
|
+
if (found) {
|
|
417
|
+
return found;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whitelist of allowed tags and their allowed attributes.
|
|
3
|
+
* Tags not in this list are unwrapped (children preserved, tag removed).
|
|
4
|
+
*/
|
|
5
|
+
const ALLOWED: Record<string, Set<string> | true> = {
|
|
6
|
+
// Inline
|
|
7
|
+
B: true,
|
|
8
|
+
STRONG: true,
|
|
9
|
+
I: true,
|
|
10
|
+
EM: true,
|
|
11
|
+
A: new Set(['href']),
|
|
12
|
+
S: true,
|
|
13
|
+
U: true,
|
|
14
|
+
CODE: true,
|
|
15
|
+
MARK: new Set(['style']),
|
|
16
|
+
BR: true,
|
|
17
|
+
// Block
|
|
18
|
+
P: true,
|
|
19
|
+
H1: true, H2: true, H3: true, H4: true, H5: true, H6: true,
|
|
20
|
+
UL: true,
|
|
21
|
+
OL: true,
|
|
22
|
+
LI: new Set(['aria-level']),
|
|
23
|
+
TABLE: true, THEAD: true, TBODY: true, TR: true,
|
|
24
|
+
TD: new Set(['style']),
|
|
25
|
+
TH: new Set(['style']),
|
|
26
|
+
BLOCKQUOTE: true,
|
|
27
|
+
PRE: true,
|
|
28
|
+
HR: true,
|
|
29
|
+
ASIDE: new Set(['style']),
|
|
30
|
+
DETAILS: true,
|
|
31
|
+
SUMMARY: true,
|
|
32
|
+
IMG: new Set(['src', 'style']),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Sanitize DOM tree in place. Removes disallowed tags (unwrapping children)
|
|
37
|
+
* and strips disallowed attributes from allowed tags.
|
|
38
|
+
*/
|
|
39
|
+
export function sanitize(wrapper: HTMLElement): void {
|
|
40
|
+
sanitizeNode(wrapper);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Unwrap a disallowed element: move its children before it, remove it,
|
|
45
|
+
* and return any element children so they can be re-queued for processing.
|
|
46
|
+
*/
|
|
47
|
+
function unwrapElement(el: HTMLElement): ChildNode[] {
|
|
48
|
+
const grandchildren = Array.from(el.childNodes);
|
|
49
|
+
|
|
50
|
+
for (const gc of grandchildren) {
|
|
51
|
+
el.before(gc);
|
|
52
|
+
}
|
|
53
|
+
el.remove();
|
|
54
|
+
|
|
55
|
+
return grandchildren.filter((gc) => gc.nodeType === gc.ELEMENT_NODE);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Strip attributes from an element that are not in the allowed set.
|
|
60
|
+
* When `allowedAttrs` is `true`, all attributes are removed.
|
|
61
|
+
*/
|
|
62
|
+
function stripAttributes(el: HTMLElement, allowedAttrs: Set<string> | true): void {
|
|
63
|
+
for (const attr of Array.from(el.attributes)) {
|
|
64
|
+
if (allowedAttrs === true || !allowedAttrs.has(attr.name)) {
|
|
65
|
+
el.removeAttribute(attr.name);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sanitizeNode(node: Node): void {
|
|
71
|
+
// Use a live-like approach: collect children, then process each.
|
|
72
|
+
// When a child is unwrapped its grandchildren are inserted in place and
|
|
73
|
+
// must themselves be processed as children of the same parent.
|
|
74
|
+
const queue = Array.from(node.childNodes);
|
|
75
|
+
|
|
76
|
+
for (const child of queue) {
|
|
77
|
+
if (child.nodeType !== child.ELEMENT_NODE) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const el = child as HTMLElement;
|
|
82
|
+
const allowedAttrs = ALLOWED[el.tagName];
|
|
83
|
+
|
|
84
|
+
if (allowedAttrs === undefined) {
|
|
85
|
+
// Unwrap disallowed tag and re-queue its element children.
|
|
86
|
+
queue.push(...unwrapElement(el));
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
stripAttributes(el, allowedAttrs);
|
|
91
|
+
sanitizeNode(el);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface OutputBlockData {
|
|
2
|
+
id: string;
|
|
3
|
+
type: string;
|
|
4
|
+
data: Record<string, unknown>;
|
|
5
|
+
parent?: string;
|
|
6
|
+
content?: string[];
|
|
7
|
+
stretched?: number | null;
|
|
8
|
+
key?: string | null;
|
|
9
|
+
width?: number | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface OutputData {
|
|
13
|
+
version: string;
|
|
14
|
+
blocks: OutputBlockData[];
|
|
15
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import { getMigrationDoc } from './commands/migration';
|
|
2
2
|
import { writeOutput } from './utils/output';
|
|
3
3
|
|
|
4
|
-
const HELP_TEXT = `Usage: blok [options]
|
|
4
|
+
const HELP_TEXT = `Usage: blok-cli [options]
|
|
5
5
|
|
|
6
6
|
Options:
|
|
7
|
+
--convert-html Convert legacy HTML from stdin to Blok JSON (stdout)
|
|
8
|
+
--convert-gdocs Convert Google Docs HTML from stdin to Blok JSON (stdout)
|
|
7
9
|
--migration Output the EditorJS to Blok migration guide (LLM-friendly)
|
|
8
10
|
--output <file> Write output to a file instead of stdout
|
|
9
11
|
--help Show this help message
|
|
10
12
|
|
|
11
13
|
Examples:
|
|
12
|
-
npx @jackuait/blok --
|
|
13
|
-
npx @jackuait/blok --
|
|
14
|
-
npx @jackuait/blok --
|
|
14
|
+
npx @jackuait/blok-cli --convert-html < article.html
|
|
15
|
+
npx @jackuait/blok-cli --convert-html < article.html --output article.json
|
|
16
|
+
npx @jackuait/blok-cli --convert-gdocs < gdocs-export.html
|
|
17
|
+
npx @jackuait/blok-cli --convert-gdocs < gdocs-export.html --output doc.json
|
|
18
|
+
npx @jackuait/blok-cli --migration
|
|
19
|
+
npx @jackuait/blok-cli --migration | pbcopy
|
|
20
|
+
npx @jackuait/blok-cli --migration --output migration-guide.md
|
|
15
21
|
`;
|
|
16
22
|
|
|
17
23
|
const parseArgs = (argv: string[]): { command: string | null; output?: string } => {
|
|
@@ -19,6 +25,20 @@ const parseArgs = (argv: string[]): { command: string | null; output?: string }
|
|
|
19
25
|
return { command: 'help' };
|
|
20
26
|
}
|
|
21
27
|
|
|
28
|
+
if (argv.includes('--convert-html')) {
|
|
29
|
+
const outputIndex = argv.indexOf('--output');
|
|
30
|
+
const output = outputIndex !== -1 ? argv[outputIndex + 1] : undefined;
|
|
31
|
+
|
|
32
|
+
return { command: 'convert-html', output };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (argv.includes('--convert-gdocs')) {
|
|
36
|
+
const outputIndex = argv.indexOf('--output');
|
|
37
|
+
const output = outputIndex !== -1 ? argv[outputIndex + 1] : undefined;
|
|
38
|
+
|
|
39
|
+
return { command: 'convert-gdocs', output };
|
|
40
|
+
}
|
|
41
|
+
|
|
22
42
|
if (argv.includes('--migration')) {
|
|
23
43
|
const outputIndex = argv.indexOf('--output');
|
|
24
44
|
const output = outputIndex !== -1 ? argv[outputIndex + 1] : undefined;
|
|
@@ -29,10 +49,41 @@ const parseArgs = (argv: string[]): { command: string | null; output?: string }
|
|
|
29
49
|
return { command: null };
|
|
30
50
|
};
|
|
31
51
|
|
|
32
|
-
export const run = (argv: string[], version: string): void => {
|
|
52
|
+
export const run = async (argv: string[], version: string): Promise<void> => {
|
|
33
53
|
const { command, output } = parseArgs(argv);
|
|
34
54
|
|
|
35
55
|
switch (command) {
|
|
56
|
+
case 'convert-html': {
|
|
57
|
+
const jsdom = await import('jsdom');
|
|
58
|
+
const dom = new jsdom.JSDOM('');
|
|
59
|
+
|
|
60
|
+
globalThis.DOMParser = dom.window.DOMParser;
|
|
61
|
+
globalThis.Node = dom.window.Node;
|
|
62
|
+
|
|
63
|
+
const { convertHtml } = await import('./commands/convert-html/index');
|
|
64
|
+
const fs = await import('node:fs');
|
|
65
|
+
const html = fs.readFileSync('/dev/stdin', 'utf-8');
|
|
66
|
+
const json = convertHtml(html);
|
|
67
|
+
|
|
68
|
+
writeOutput(json, output);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
case 'convert-gdocs': {
|
|
72
|
+
const jsdom = await import('jsdom');
|
|
73
|
+
const dom = new jsdom.JSDOM('');
|
|
74
|
+
|
|
75
|
+
globalThis.DOMParser = dom.window.DOMParser;
|
|
76
|
+
globalThis.Node = dom.window.Node;
|
|
77
|
+
(globalThis as Record<string, unknown>).document = dom.window.document;
|
|
78
|
+
|
|
79
|
+
const { convertGdocs } = await import('./commands/convert-gdocs/index');
|
|
80
|
+
const fs = await import('node:fs');
|
|
81
|
+
const html = fs.readFileSync('/dev/stdin', 'utf-8');
|
|
82
|
+
const json = convertGdocs(html);
|
|
83
|
+
|
|
84
|
+
writeOutput(json, output);
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
36
87
|
case 'migration': {
|
|
37
88
|
const content = getMigrationDoc(version);
|
|
38
89
|
|
|
@@ -202,6 +202,11 @@ export class Block extends EventsDispatcher<BlockEvents> {
|
|
|
202
202
|
*/
|
|
203
203
|
private readonly blockAPI: BlockAPIInterface;
|
|
204
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Current read-only state of the block
|
|
207
|
+
*/
|
|
208
|
+
private readOnly: boolean;
|
|
209
|
+
|
|
205
210
|
/**
|
|
206
211
|
* Cleanup function for draggable behavior
|
|
207
212
|
*/
|
|
@@ -261,6 +266,7 @@ export class Block extends EventsDispatcher<BlockEvents> {
|
|
|
261
266
|
this.blokEventBus = eventBus || null;
|
|
262
267
|
this.blockAPI = new BlockAPI(this);
|
|
263
268
|
|
|
269
|
+
this.readOnly = readOnly;
|
|
264
270
|
this.tool = tool;
|
|
265
271
|
this.toolInstance = tool.create(data, this.blockAPI, readOnly);
|
|
266
272
|
this.tunes = tool.tunes;
|
|
@@ -324,18 +330,21 @@ export class Block extends EventsDispatcher<BlockEvents> {
|
|
|
324
330
|
);
|
|
325
331
|
|
|
326
332
|
// Bind block mutation watchers and input events
|
|
333
|
+
// - Skip entirely when block is in read-only mode (no mutations to track, no input events)
|
|
327
334
|
// - Immediately if bindMutationWatchersImmediately is true (for user-created blocks)
|
|
328
335
|
// - Deferred via requestIdleCallback otherwise (for initial load optimization)
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
336
|
+
if (!readOnly) {
|
|
337
|
+
const bindEvents = (): void => {
|
|
338
|
+
this.mutationHandler.watch();
|
|
339
|
+
this.inputManager.addInputEvents();
|
|
340
|
+
this.toggleInputsEmptyMark();
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
if (bindMutationWatchersImmediately) {
|
|
344
|
+
bindEvents();
|
|
345
|
+
} else {
|
|
346
|
+
window.requestIdleCallback(bindEvents);
|
|
347
|
+
}
|
|
339
348
|
}
|
|
340
349
|
}
|
|
341
350
|
|
|
@@ -507,6 +516,31 @@ export class Block extends EventsDispatcher<BlockEvents> {
|
|
|
507
516
|
}
|
|
508
517
|
}
|
|
509
518
|
|
|
519
|
+
/**
|
|
520
|
+
* Toggle read-only mode in place without destroying the block.
|
|
521
|
+
* Updates internal managers and notifies the tool if it supports in-place toggle.
|
|
522
|
+
* @param state - new read-only state
|
|
523
|
+
*/
|
|
524
|
+
public setReadOnly(state: boolean): void {
|
|
525
|
+
if (this.readOnly === state) {
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
this.readOnly = state;
|
|
530
|
+
|
|
531
|
+
if (state) {
|
|
532
|
+
this.inputManager.removeInputEvents();
|
|
533
|
+
this.mutationHandler.unwatch();
|
|
534
|
+
} else {
|
|
535
|
+
this.inputManager.addInputEvents();
|
|
536
|
+
this.mutationHandler.watch();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (typeof (this.toolInstance as unknown as { setReadOnly?: unknown }).setReadOnly === 'function') {
|
|
540
|
+
(this.toolInstance as unknown as { setReadOnly: (s: boolean) => void }).setReadOnly(state);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
510
544
|
/**
|
|
511
545
|
* Tool could specify several entries to be displayed at the Toolbox (for example, "Heading 1", "Heading 2", "Heading 3")
|
|
512
546
|
* This method returns the entry that is related to the Block (depended on the Block data)
|