@jackuait/blok 0.10.0-beta.13 → 0.10.0-beta.15
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/bin/convert-html.mjs +3 -0
- package/dist/blok.mjs +2 -2
- package/dist/chunks/{blok-uTkAJyAg.mjs → blok-1213fGsk.mjs} +275 -260
- package/dist/chunks/{constants-NpHRgdEj.mjs → constants-Cr7GEExc.mjs} +1 -1
- package/dist/chunks/{tools-bN22O_3E.mjs → tools-DBEfU2dP.mjs} +1 -1
- package/dist/convert-html.mjs +631 -0
- package/dist/full.mjs +3 -3
- package/dist/react.mjs +2 -2
- package/dist/tools.mjs +2 -2
- package/package.json +3 -2
- package/src/cli/commands/convert-html/block-builder.ts +382 -0
- package/src/cli/commands/convert-html/id-generator.ts +11 -0
- package/src/cli/commands/convert-html/index.ts +21 -0
- package/src/cli/commands/convert-html/preprocessor.ts +348 -0
- package/src/cli/commands/convert-html/sanitizer.ts +94 -0
- package/src/cli/commands/convert-html/standalone.ts +20 -0
- package/src/cli/commands/convert-html/types.ts +15 -0
- package/src/cli/index.ts +30 -5
- package/src/components/modules/toolbar/index.ts +20 -2
- package/src/components/modules/uiControllers/controllers/blockHover.ts +44 -1
- package/dist/cli.mjs +0 -37
|
@@ -0,0 +1,348 @@
|
|
|
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
|
+
let bareDivs: HTMLElement[];
|
|
53
|
+
|
|
54
|
+
while ((bareDivs = Array.from(aside.querySelectorAll<HTMLElement>(':scope > div'))
|
|
55
|
+
.filter((d) => !d.getAttribute('style') && !d.getAttribute('class'))).length > 0) {
|
|
56
|
+
for (const child of bareDivs) {
|
|
57
|
+
child.replaceWith(...Array.from(child.childNodes));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Strip trailing <br> inside paragraphs — the paste handler splits
|
|
62
|
+
// content at <br> boundaries, so a trailing one creates an empty block.
|
|
63
|
+
for (const p of Array.from(aside.querySelectorAll('p'))) {
|
|
64
|
+
const lastChild = p.lastElementChild;
|
|
65
|
+
|
|
66
|
+
if (lastChild?.tagName === 'BR') {
|
|
67
|
+
lastChild.remove();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
div.replaceWith(aside);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Minimum channel brightness for a colour to be considered "near-white".
|
|
77
|
+
*
|
|
78
|
+
* Any `rgb(r, g, b)` where all three channels are >= this value is stripped.
|
|
79
|
+
*/
|
|
80
|
+
const NEAR_WHITE_MIN_CHANNEL = 250;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Remove white/transparent `background-color` from inline elements.
|
|
84
|
+
*
|
|
85
|
+
* After stripping the property, empty wrapper elements (no remaining styles
|
|
86
|
+
* and no text) are unwrapped.
|
|
87
|
+
*/
|
|
88
|
+
function stripSpuriousBackgroundColors(wrapper: HTMLElement): void {
|
|
89
|
+
const candidates = wrapper.querySelectorAll<HTMLElement>('[style*="background-color"]');
|
|
90
|
+
|
|
91
|
+
for (const el of Array.from(candidates)) {
|
|
92
|
+
if (isSpuriousBackgroundColor(el.style.backgroundColor)) {
|
|
93
|
+
el.style.removeProperty('background-color');
|
|
94
|
+
|
|
95
|
+
if (el.getAttribute('style')?.trim() === '') {
|
|
96
|
+
el.removeAttribute('style');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (isEmptyWrapper(el)) {
|
|
100
|
+
el.replaceWith(...Array.from(el.childNodes));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Check whether a computed `background-color` value is visually invisible
|
|
108
|
+
* (white, near-white, or transparent).
|
|
109
|
+
*/
|
|
110
|
+
function isSpuriousBackgroundColor(value: string): boolean {
|
|
111
|
+
if (!value) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const normalised = value.replace(/\s/g, '').toLowerCase();
|
|
116
|
+
|
|
117
|
+
if (normalised === 'transparent') {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const rgbaMatch = normalised.match(/^rgba?\((\d+),(\d+),(\d+)(?:,([^)]+))?\)$/);
|
|
122
|
+
|
|
123
|
+
if (!rgbaMatch) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const alpha = rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1;
|
|
128
|
+
|
|
129
|
+
if (alpha === 0) {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const r = parseInt(rgbaMatch[1], 10);
|
|
134
|
+
const g = parseInt(rgbaMatch[2], 10);
|
|
135
|
+
const b = parseInt(rgbaMatch[3], 10);
|
|
136
|
+
|
|
137
|
+
return r >= NEAR_WHITE_MIN_CHANNEL && g >= NEAR_WHITE_MIN_CHANNEL && b >= NEAR_WHITE_MIN_CHANNEL;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Check whether an element is a pure wrapper with no semantic value
|
|
142
|
+
* (no attributes and no text content, or just whitespace).
|
|
143
|
+
*/
|
|
144
|
+
function isEmptyWrapper(el: HTMLElement): boolean {
|
|
145
|
+
if (el.attributes.length > 0) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const text = (el.textContent ?? '').replace(/[\s\u00A0]/g, '');
|
|
150
|
+
|
|
151
|
+
return text.length === 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Extract `background-color` from an element's style attribute.
|
|
156
|
+
*
|
|
157
|
+
* Handles both `background-color:` and `background:` (shorthand) properties.
|
|
158
|
+
* Uses the element's computed style property first, falling back to manual
|
|
159
|
+
* parsing of the style attribute for shorthand notation.
|
|
160
|
+
*/
|
|
161
|
+
function getBackgroundColor(el: HTMLElement): string {
|
|
162
|
+
// Try the direct property first
|
|
163
|
+
if (el.style.backgroundColor) {
|
|
164
|
+
return el.style.backgroundColor;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Fall back to parsing the style attribute for shorthand `background:`
|
|
168
|
+
const styleAttr = el.getAttribute('style') ?? '';
|
|
169
|
+
const match = styleAttr.match(/background:\s*([^;]+)/i);
|
|
170
|
+
|
|
171
|
+
if (match) {
|
|
172
|
+
const value = match[1].trim();
|
|
173
|
+
// Only return color values, not url() or other background sub-properties
|
|
174
|
+
const colorMatch = value.match(/^(rgb[a]?\([^)]+\)|#[0-9a-fA-F]{3,8}|[a-z]+)$/i);
|
|
175
|
+
|
|
176
|
+
if (colorMatch) {
|
|
177
|
+
return colorMatch[1];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return '';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Convert `<p>` boundaries to `<br>` line breaks inside table cells.
|
|
186
|
+
*
|
|
187
|
+
* Only targets `<td>` and `<th>` — top-level `<p>` tags are left intact.
|
|
188
|
+
*/
|
|
189
|
+
function convertTableCellParagraphs(wrapper: HTMLElement): void {
|
|
190
|
+
const doc = wrapper.ownerDocument;
|
|
191
|
+
|
|
192
|
+
for (const cell of Array.from(wrapper.querySelectorAll('td, th'))) {
|
|
193
|
+
const paragraphs = cell.querySelectorAll('p');
|
|
194
|
+
|
|
195
|
+
if (paragraphs.length === 0) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
for (const p of Array.from(paragraphs)) {
|
|
200
|
+
if (p.innerHTML.trim() === '' || p.innerHTML.trim() === ' ') {
|
|
201
|
+
p.remove();
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const fragment = doc.createDocumentFragment();
|
|
206
|
+
|
|
207
|
+
fragment.append(...Array.from(p.childNodes));
|
|
208
|
+
fragment.append(doc.createElement('br'));
|
|
209
|
+
p.replaceWith(fragment);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
stripTrailingBreaks(cell);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Remove paragraphs whose only content is non-breaking spaces or whitespace.
|
|
218
|
+
*/
|
|
219
|
+
function stripNbspOnlyParagraphs(wrapper: HTMLElement): void {
|
|
220
|
+
for (const p of Array.from(wrapper.querySelectorAll('p'))) {
|
|
221
|
+
// Skip paragraphs inside table cells — those are handled by convertTableCellParagraphs
|
|
222
|
+
if (p.closest('td') || p.closest('th')) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const textContent = p.textContent ?? '';
|
|
227
|
+
const stripped = textContent.replace(/[\s\u00A0]/g, '');
|
|
228
|
+
|
|
229
|
+
if (stripped.length === 0) {
|
|
230
|
+
p.remove();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Convert `<del>` and `<strike>` elements to `<s>`.
|
|
237
|
+
*/
|
|
238
|
+
function convertStrikethroughTags(wrapper: HTMLElement): void {
|
|
239
|
+
const doc = wrapper.ownerDocument;
|
|
240
|
+
|
|
241
|
+
for (const el of Array.from(wrapper.querySelectorAll('del, strike'))) {
|
|
242
|
+
const replacement = doc.createElement('s');
|
|
243
|
+
|
|
244
|
+
replacement.append(...Array.from(el.childNodes));
|
|
245
|
+
el.replaceWith(replacement);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Bullet characters that indicate a pseudo-list paragraph.
|
|
251
|
+
*
|
|
252
|
+
* Matches: `\u2022` (bullet), `\u00B7` (middle dot), or `- ` (hyphen + space).
|
|
253
|
+
*/
|
|
254
|
+
const BULLET_PREFIX = /^[\u2022\u00B7][\s\u00A0]*|^-\s/;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Convert `<p>• text</p>` pseudo-lists into proper `<ul><li>` markup.
|
|
258
|
+
*
|
|
259
|
+
* Groups consecutive bullet paragraphs into a single `<ul>` and strips
|
|
260
|
+
* the bullet prefix. Only processes direct children of the wrapper.
|
|
261
|
+
*/
|
|
262
|
+
function convertBulletParagraphsToLists(wrapper: HTMLElement): void {
|
|
263
|
+
const doc = wrapper.ownerDocument;
|
|
264
|
+
const children = Array.from(wrapper.childNodes);
|
|
265
|
+
let currentList: HTMLUListElement | null = null;
|
|
266
|
+
|
|
267
|
+
for (const child of children) {
|
|
268
|
+
if (child.nodeType !== Node.ELEMENT_NODE) {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const el = child as HTMLElement;
|
|
273
|
+
|
|
274
|
+
if (el.tagName !== 'P') {
|
|
275
|
+
currentList = null;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const textContent = el.textContent ?? '';
|
|
280
|
+
|
|
281
|
+
if (!BULLET_PREFIX.test(textContent)) {
|
|
282
|
+
currentList = null;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!currentList) {
|
|
287
|
+
currentList = doc.createElement('ul');
|
|
288
|
+
el.before(currentList);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const li = doc.createElement('li');
|
|
292
|
+
|
|
293
|
+
// Strip the bullet character and any leading nbsp/whitespace from
|
|
294
|
+
// the first text node, preserving any inline HTML that follows.
|
|
295
|
+
const firstTextNode = findFirstTextNode(el);
|
|
296
|
+
|
|
297
|
+
if (firstTextNode) {
|
|
298
|
+
firstTextNode.textContent = (firstTextNode.textContent ?? '').replace(BULLET_PREFIX, '');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
li.append(...Array.from(el.childNodes));
|
|
302
|
+
currentList.appendChild(li);
|
|
303
|
+
el.remove();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Remove trailing `<br>` elements and whitespace-only text nodes from the end
|
|
309
|
+
* of an element.
|
|
310
|
+
*/
|
|
311
|
+
function stripTrailingBreaks(element: Element): void {
|
|
312
|
+
let node = element.lastChild;
|
|
313
|
+
|
|
314
|
+
while (node) {
|
|
315
|
+
if (node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName === 'BR') {
|
|
316
|
+
const prev = node.previousSibling;
|
|
317
|
+
|
|
318
|
+
node.remove();
|
|
319
|
+
node = prev;
|
|
320
|
+
} else if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim() === '') {
|
|
321
|
+
const prev = node.previousSibling;
|
|
322
|
+
|
|
323
|
+
node.remove();
|
|
324
|
+
node = prev;
|
|
325
|
+
} else {
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Walk the DOM tree depth-first to find the first Text node.
|
|
333
|
+
*/
|
|
334
|
+
function findFirstTextNode(node: Node): Text | null {
|
|
335
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
336
|
+
return node as Text;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
for (const child of Array.from(node.childNodes)) {
|
|
340
|
+
const found = findFirstTextNode(child);
|
|
341
|
+
|
|
342
|
+
if (found) {
|
|
343
|
+
return found;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
function sanitizeNode(node: Node): void {
|
|
44
|
+
// Use a live-like approach: collect children, then process each.
|
|
45
|
+
// When a child is unwrapped its grandchildren are inserted in place and
|
|
46
|
+
// must themselves be processed as children of the same parent.
|
|
47
|
+
const queue = Array.from(node.childNodes);
|
|
48
|
+
|
|
49
|
+
for (const child of queue) {
|
|
50
|
+
if (child.nodeType !== child.ELEMENT_NODE) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const el = child as HTMLElement;
|
|
55
|
+
const tag = el.tagName;
|
|
56
|
+
const allowedAttrs = ALLOWED[tag];
|
|
57
|
+
|
|
58
|
+
if (allowedAttrs === undefined) {
|
|
59
|
+
// Unwrap: move children to the parent, then remove this element.
|
|
60
|
+
const grandchildren = Array.from(el.childNodes);
|
|
61
|
+
|
|
62
|
+
for (const gc of grandchildren) {
|
|
63
|
+
el.before(gc);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
el.remove();
|
|
67
|
+
|
|
68
|
+
// Push the moved grandchildren onto the queue so they are evaluated
|
|
69
|
+
// for unwrapping / attribute-stripping in the same parent context.
|
|
70
|
+
for (const gc of grandchildren) {
|
|
71
|
+
if (gc.nodeType === gc.ELEMENT_NODE) {
|
|
72
|
+
queue.push(gc);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
// Strip disallowed attributes.
|
|
77
|
+
if (allowedAttrs !== true) {
|
|
78
|
+
for (const attr of Array.from(el.attributes)) {
|
|
79
|
+
if (!allowedAttrs.has(attr.name)) {
|
|
80
|
+
el.removeAttribute(attr.name);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
// true means no attributes allowed — strip all.
|
|
85
|
+
for (const attr of Array.from(el.attributes)) {
|
|
86
|
+
el.removeAttribute(attr.name);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Recurse into children.
|
|
91
|
+
sanitizeNode(el);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import { JSDOM } from 'jsdom';
|
|
3
|
+
|
|
4
|
+
// Provide DOM globals for convertHtml and block-builder
|
|
5
|
+
const dom = new JSDOM('');
|
|
6
|
+
globalThis.DOMParser = dom.window.DOMParser;
|
|
7
|
+
globalThis.Node = dom.window.Node;
|
|
8
|
+
|
|
9
|
+
async function main(): Promise<void> {
|
|
10
|
+
const { convertHtml } = await import('./index');
|
|
11
|
+
const html = fs.readFileSync('/dev/stdin', 'utf-8');
|
|
12
|
+
const json = convertHtml(html);
|
|
13
|
+
|
|
14
|
+
process.stdout.write(json);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
main().catch((err) => {
|
|
18
|
+
process.stderr.write(`Error: ${(err as Error).message}\n`);
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
});
|
|
@@ -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,20 @@
|
|
|
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)
|
|
7
8
|
--migration Output the EditorJS to Blok migration guide (LLM-friendly)
|
|
8
9
|
--output <file> Write output to a file instead of stdout
|
|
9
10
|
--help Show this help message
|
|
10
11
|
|
|
11
12
|
Examples:
|
|
12
|
-
npx @jackuait/blok --
|
|
13
|
-
npx @jackuait/blok --
|
|
14
|
-
npx @jackuait/blok --migration
|
|
13
|
+
npx @jackuait/blok-cli --convert-html < article.html
|
|
14
|
+
npx @jackuait/blok-cli --convert-html < article.html --output article.json
|
|
15
|
+
npx @jackuait/blok-cli --migration
|
|
16
|
+
npx @jackuait/blok-cli --migration | pbcopy
|
|
17
|
+
npx @jackuait/blok-cli --migration --output migration-guide.md
|
|
15
18
|
`;
|
|
16
19
|
|
|
17
20
|
const parseArgs = (argv: string[]): { command: string | null; output?: string } => {
|
|
@@ -19,6 +22,13 @@ const parseArgs = (argv: string[]): { command: string | null; output?: string }
|
|
|
19
22
|
return { command: 'help' };
|
|
20
23
|
}
|
|
21
24
|
|
|
25
|
+
if (argv.includes('--convert-html')) {
|
|
26
|
+
const outputIndex = argv.indexOf('--output');
|
|
27
|
+
const output = outputIndex !== -1 ? argv[outputIndex + 1] : undefined;
|
|
28
|
+
|
|
29
|
+
return { command: 'convert-html', output };
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
if (argv.includes('--migration')) {
|
|
23
33
|
const outputIndex = argv.indexOf('--output');
|
|
24
34
|
const output = outputIndex !== -1 ? argv[outputIndex + 1] : undefined;
|
|
@@ -29,10 +39,25 @@ const parseArgs = (argv: string[]): { command: string | null; output?: string }
|
|
|
29
39
|
return { command: null };
|
|
30
40
|
};
|
|
31
41
|
|
|
32
|
-
export const run = (argv: string[], version: string): void => {
|
|
42
|
+
export const run = async (argv: string[], version: string): Promise<void> => {
|
|
33
43
|
const { command, output } = parseArgs(argv);
|
|
34
44
|
|
|
35
45
|
switch (command) {
|
|
46
|
+
case 'convert-html': {
|
|
47
|
+
const jsdom = await import('jsdom') as { JSDOM: new (html: string) => { window: typeof globalThis } };
|
|
48
|
+
const dom = new jsdom.JSDOM('');
|
|
49
|
+
|
|
50
|
+
globalThis.DOMParser = dom.window.DOMParser;
|
|
51
|
+
globalThis.Node = dom.window.Node;
|
|
52
|
+
|
|
53
|
+
const { convertHtml } = await import('./commands/convert-html/index');
|
|
54
|
+
const fs = await import('node:fs');
|
|
55
|
+
const html = fs.readFileSync('/dev/stdin', 'utf-8');
|
|
56
|
+
const json = convertHtml(html);
|
|
57
|
+
|
|
58
|
+
writeOutput(json, output);
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
36
61
|
case 'migration': {
|
|
37
62
|
const content = getMigrationDoc(version);
|
|
38
63
|
|
|
@@ -505,6 +505,22 @@ export class Toolbar extends Module<ToolbarNodes> {
|
|
|
505
505
|
|
|
506
506
|
this.open();
|
|
507
507
|
|
|
508
|
+
/**
|
|
509
|
+
* For blocks with interactive elements at the left edge (toggle arrows,
|
|
510
|
+
* callout emoji buttons), disable pointer-events on the actions
|
|
511
|
+
* container so clicks pass through to the block content.
|
|
512
|
+
* Must run after open() which sets pointer-events: auto on actions.
|
|
513
|
+
*/
|
|
514
|
+
const isToggleHeader = targetBlock.name === 'header'
|
|
515
|
+
&& targetBlock.holder.querySelector('[data-blok-toggle-arrow]') !== null;
|
|
516
|
+
const hasLeftEdgeInteraction = targetBlock.name === 'callout'
|
|
517
|
+
|| targetBlock.name === 'toggle'
|
|
518
|
+
|| isToggleHeader;
|
|
519
|
+
|
|
520
|
+
if (hasLeftEdgeInteraction && this.nodes.actions) {
|
|
521
|
+
this.nodes.actions.style.pointerEvents = 'none';
|
|
522
|
+
}
|
|
523
|
+
|
|
508
524
|
/**
|
|
509
525
|
* Sync toolbar content wrapper's margin with the block content element
|
|
510
526
|
* so toolbar buttons align with the block content edge, even when
|
|
@@ -514,8 +530,9 @@ export class Toolbar extends Module<ToolbarNodes> {
|
|
|
514
530
|
*/
|
|
515
531
|
if (blockContentElement && this.nodes.content) {
|
|
516
532
|
const blockMarginLeft = parseFloat(getComputedStyle(blockContentElement).marginLeft) || 0;
|
|
533
|
+
const actionsWidth = this.nodes.actions?.offsetWidth ?? 0;
|
|
517
534
|
|
|
518
|
-
this.nodes.content.style.marginLeft = `${blockMarginLeft}px`;
|
|
535
|
+
this.nodes.content.style.marginLeft = `${Math.max(blockMarginLeft, actionsWidth)}px`;
|
|
519
536
|
}
|
|
520
537
|
}
|
|
521
538
|
|
|
@@ -641,8 +658,9 @@ export class Toolbar extends Module<ToolbarNodes> {
|
|
|
641
658
|
*/
|
|
642
659
|
if (blockContentElement && this.nodes.content) {
|
|
643
660
|
const blockMarginLeft = parseFloat(getComputedStyle(blockContentElement).marginLeft) || 0;
|
|
661
|
+
const actionsWidth = this.nodes.actions?.offsetWidth ?? 0;
|
|
644
662
|
|
|
645
|
-
this.nodes.content.style.marginLeft = `${blockMarginLeft}px`;
|
|
663
|
+
this.nodes.content.style.marginLeft = `${Math.max(blockMarginLeft, actionsWidth)}px`;
|
|
646
664
|
}
|
|
647
665
|
}
|
|
648
666
|
|
|
@@ -34,6 +34,13 @@ export class BlockHoverController extends Controller {
|
|
|
34
34
|
*/
|
|
35
35
|
private static readonly HOVER_COOLDOWN_MS = 50;
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Maximum horizontal distance from content edges for extended hover zone.
|
|
39
|
+
* When cursor is within this distance of the content area, nearest-block
|
|
40
|
+
* detection activates. Beyond this distance, no hover event is emitted.
|
|
41
|
+
*/
|
|
42
|
+
private static readonly HOVER_ZONE_SIZE = 100;
|
|
43
|
+
|
|
37
44
|
constructor(options: {
|
|
38
45
|
config: Controller['config'];
|
|
39
46
|
eventsDispatcher: Controller['eventsDispatcher'];
|
|
@@ -104,9 +111,10 @@ export class BlockHoverController extends Controller {
|
|
|
104
111
|
|
|
105
112
|
/**
|
|
106
113
|
* If no block element found directly, find the nearest block by Y distance
|
|
114
|
+
* but only if the cursor is within the extended hover zone (100px from content edges).
|
|
107
115
|
*/
|
|
108
116
|
if (!hoveredBlockElement) {
|
|
109
|
-
this.
|
|
117
|
+
this.emitNearestBlockHoveredInZone(event.clientX, event.clientY);
|
|
110
118
|
|
|
111
119
|
return;
|
|
112
120
|
}
|
|
@@ -169,6 +177,41 @@ export class BlockHoverController extends Controller {
|
|
|
169
177
|
});
|
|
170
178
|
}
|
|
171
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Emits a BlockHovered event for the nearest block, but only if the cursor
|
|
182
|
+
* is within the extended hover zone (HOVER_ZONE_SIZE px from content edges).
|
|
183
|
+
* @param clientX - Cursor X position
|
|
184
|
+
* @param clientY - Cursor Y position
|
|
185
|
+
*/
|
|
186
|
+
private emitNearestBlockHoveredInZone(clientX: number, clientY: number): void {
|
|
187
|
+
const blocks = this.Blok.BlockManager.blocks;
|
|
188
|
+
const topLevelBlocks = blocks.filter(block =>
|
|
189
|
+
block.holder.closest('[data-blok-table-cell-blocks], [data-blok-toggle-children]') === null
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
if (topLevelBlocks.length === 0) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const contentEl = topLevelBlocks[0].holder.querySelector<HTMLElement>('[data-blok-element-content]');
|
|
197
|
+
|
|
198
|
+
if (!contentEl) {
|
|
199
|
+
this.emitNearestBlockHovered(clientY);
|
|
200
|
+
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const contentRect = contentEl.getBoundingClientRect();
|
|
205
|
+
const distLeft = Math.abs(clientX - contentRect.left);
|
|
206
|
+
const distRight = Math.abs(clientX - contentRect.right);
|
|
207
|
+
const withinZone = distLeft <= BlockHoverController.HOVER_ZONE_SIZE
|
|
208
|
+
|| distRight <= BlockHoverController.HOVER_ZONE_SIZE;
|
|
209
|
+
|
|
210
|
+
if (withinZone) {
|
|
211
|
+
this.emitNearestBlockHovered(clientY);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
172
215
|
/**
|
|
173
216
|
* Finds the nearest block by vertical distance to cursor position.
|
|
174
217
|
* Returns the block whose vertical center is closest to the cursor Y position.
|