meno-core 1.0.21 → 1.0.23
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/build-static.test.ts +424 -0
- package/build-static.ts +100 -13
- package/lib/client/ClientInitializer.ts +4 -0
- package/lib/client/core/ComponentBuilder.ts +155 -16
- package/lib/client/core/builders/embedBuilder.ts +48 -6
- package/lib/client/core/builders/linkBuilder.ts +2 -2
- package/lib/client/core/builders/linkNodeBuilder.ts +45 -5
- package/lib/client/core/builders/listBuilder.ts +12 -3
- package/lib/client/routing/Router.tsx +8 -1
- package/lib/client/templateEngine.ts +89 -98
- package/lib/server/__integration__/api-routes.test.ts +148 -0
- package/lib/server/__integration__/cms-integration.test.ts +161 -0
- package/lib/server/__integration__/server-lifecycle.test.ts +101 -0
- package/lib/server/__integration__/ssr-rendering.test.ts +131 -0
- package/lib/server/__integration__/static-assets.test.ts +80 -0
- package/lib/server/__integration__/test-helpers.ts +205 -0
- package/lib/server/ab/generateFunctions.ts +346 -0
- package/lib/server/ab/trackingScript.ts +45 -0
- package/lib/server/index.ts +2 -2
- package/lib/server/jsonLoader.ts +124 -46
- package/lib/server/routes/api/cms.ts +3 -2
- package/lib/server/routes/api/components.ts +13 -2
- package/lib/server/services/cmsService.ts +0 -5
- package/lib/server/services/componentService.ts +255 -29
- package/lib/server/services/configService.test.ts +950 -0
- package/lib/server/services/configService.ts +39 -0
- package/lib/server/services/index.ts +1 -1
- package/lib/server/ssr/htmlGenerator.test.ts +992 -0
- package/lib/server/ssr/htmlGenerator.ts +3 -3
- package/lib/server/ssr/imageMetadata.test.ts +168 -0
- package/lib/server/ssr/imageMetadata.ts +58 -0
- package/lib/server/ssr/jsCollector.test.ts +287 -0
- package/lib/server/ssr/ssrRenderer.test.ts +3702 -0
- package/lib/server/ssr/ssrRenderer.ts +131 -15
- package/lib/shared/constants.ts +3 -0
- package/lib/shared/fontLoader.test.ts +335 -0
- package/lib/shared/i18n.test.ts +106 -0
- package/lib/shared/i18n.ts +17 -11
- package/lib/shared/index.ts +3 -0
- package/lib/shared/itemTemplateUtils.ts +43 -1
- package/lib/shared/libraryLoader.test.ts +392 -0
- package/lib/shared/linkUtils.ts +24 -0
- package/lib/shared/nodeUtils.test.ts +100 -0
- package/lib/shared/nodeUtils.ts +43 -0
- package/lib/shared/registry/NodeTypeDefinition.ts +2 -2
- package/lib/shared/registry/nodeTypes/ListNodeType.ts +20 -2
- package/lib/shared/richtext/htmlToTiptap.test.ts +948 -0
- package/lib/shared/richtext/htmlToTiptap.ts +46 -2
- package/lib/shared/richtext/tiptapToHtml.ts +65 -0
- package/lib/shared/richtext/types.ts +4 -1
- package/lib/shared/types/cms.ts +2 -0
- package/lib/shared/types/components.ts +12 -3
- package/lib/shared/types/experiments.ts +55 -0
- package/lib/shared/types/index.ts +10 -0
- package/lib/shared/utils.ts +2 -6
- package/lib/shared/validation/propValidator.test.ts +50 -0
- package/lib/shared/validation/propValidator.ts +2 -2
- package/lib/shared/validation/schemas.ts +10 -2
- package/package.json +1 -1
|
@@ -0,0 +1,948 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML to Tiptap JSON Converter Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests the conversion of HTML strings into Tiptap document format.
|
|
5
|
+
* Uses happy-dom DOMParser for browser-like HTML parsing.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, test, expect, beforeAll } from 'bun:test';
|
|
8
|
+
import { Window } from 'happy-dom';
|
|
9
|
+
import { htmlToTiptap } from './htmlToTiptap';
|
|
10
|
+
import type { TiptapDocument, TiptapNode } from './types';
|
|
11
|
+
|
|
12
|
+
// Register DOMParser globally so htmlToTiptap uses the DOM-based path
|
|
13
|
+
beforeAll(() => {
|
|
14
|
+
const win = new Window({ url: 'http://localhost:3000' });
|
|
15
|
+
(globalThis as any).DOMParser = win.DOMParser;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Helpers
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/** Shorthand to find a node by type within arbitrary depth */
|
|
23
|
+
function findNode(doc: TiptapDocument, type: string): TiptapNode | undefined {
|
|
24
|
+
function search(nodes: TiptapNode[]): TiptapNode | undefined {
|
|
25
|
+
for (const n of nodes) {
|
|
26
|
+
if (n.type === type) return n;
|
|
27
|
+
if (n.content) {
|
|
28
|
+
const found = search(n.content);
|
|
29
|
+
if (found) return found;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
return search(doc.content);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Collect all nodes of a given type */
|
|
38
|
+
function findAllNodes(doc: TiptapDocument, type: string): TiptapNode[] {
|
|
39
|
+
const result: TiptapNode[] = [];
|
|
40
|
+
function search(nodes: TiptapNode[]) {
|
|
41
|
+
for (const n of nodes) {
|
|
42
|
+
if (n.type === type) result.push(n);
|
|
43
|
+
if (n.content) search(n.content);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
search(doc.content);
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ===========================================================================
|
|
51
|
+
// Tests
|
|
52
|
+
// ===========================================================================
|
|
53
|
+
|
|
54
|
+
describe('htmlToTiptap', () => {
|
|
55
|
+
// -------------------------------------------------------------------------
|
|
56
|
+
// Basic / empty input
|
|
57
|
+
// -------------------------------------------------------------------------
|
|
58
|
+
describe('basic input handling', () => {
|
|
59
|
+
test('should return empty doc for empty string', () => {
|
|
60
|
+
const result = htmlToTiptap('');
|
|
61
|
+
expect(result).toEqual({ type: 'doc', content: [] });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('should return empty doc for null input', () => {
|
|
65
|
+
const result = htmlToTiptap(null as any);
|
|
66
|
+
expect(result).toEqual({ type: 'doc', content: [] });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('should return empty doc for undefined input', () => {
|
|
70
|
+
const result = htmlToTiptap(undefined as any);
|
|
71
|
+
expect(result).toEqual({ type: 'doc', content: [] });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('should wrap plain text in a paragraph', () => {
|
|
75
|
+
const result = htmlToTiptap('Hello world');
|
|
76
|
+
expect(result.type).toBe('doc');
|
|
77
|
+
expect(result.content.length).toBe(1);
|
|
78
|
+
expect(result.content[0].type).toBe('paragraph');
|
|
79
|
+
expect(result.content[0].content).toBeDefined();
|
|
80
|
+
expect(result.content[0].content![0].type).toBe('text');
|
|
81
|
+
expect(result.content[0].content![0].text).toBe('Hello world');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// -------------------------------------------------------------------------
|
|
86
|
+
// Block elements
|
|
87
|
+
// -------------------------------------------------------------------------
|
|
88
|
+
describe('block elements', () => {
|
|
89
|
+
test('should convert <p> to paragraph node', () => {
|
|
90
|
+
const result = htmlToTiptap('<p>Hello</p>');
|
|
91
|
+
expect(result.content.length).toBe(1);
|
|
92
|
+
expect(result.content[0].type).toBe('paragraph');
|
|
93
|
+
expect(result.content[0].content![0].text).toBe('Hello');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('should convert <p> with text-align style to paragraph with textAlign attr', () => {
|
|
97
|
+
const result = htmlToTiptap('<p style="text-align: center">Centered</p>');
|
|
98
|
+
const p = result.content[0];
|
|
99
|
+
expect(p.type).toBe('paragraph');
|
|
100
|
+
expect(p.attrs).toBeDefined();
|
|
101
|
+
expect(p.attrs!.textAlign).toBe('center');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('should convert <p> with text-align justify', () => {
|
|
105
|
+
const result = htmlToTiptap('<p style="text-align: justify">Justified</p>');
|
|
106
|
+
expect(result.content[0].attrs!.textAlign).toBe('justify');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('should not set textAlign for unsupported alignment value', () => {
|
|
110
|
+
const result = htmlToTiptap('<p style="text-align: start">Text</p>');
|
|
111
|
+
const p = result.content[0];
|
|
112
|
+
// 'start' is not in the allowed list
|
|
113
|
+
expect(p.attrs?.textAlign).toBeUndefined();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('headings', () => {
|
|
117
|
+
for (let level = 1; level <= 6; level++) {
|
|
118
|
+
test(`should convert <h${level}> to heading with level ${level}`, () => {
|
|
119
|
+
const result = htmlToTiptap(`<h${level}>Heading ${level}</h${level}>`);
|
|
120
|
+
const heading = result.content[0];
|
|
121
|
+
expect(heading.type).toBe('heading');
|
|
122
|
+
expect(heading.attrs!.level).toBe(level);
|
|
123
|
+
expect(heading.content![0].text).toBe(`Heading ${level}`);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
test('should convert <h2> with text-align to heading with textAlign', () => {
|
|
128
|
+
const result = htmlToTiptap('<h2 style="text-align: right">Right Heading</h2>');
|
|
129
|
+
const heading = result.content[0];
|
|
130
|
+
expect(heading.type).toBe('heading');
|
|
131
|
+
expect(heading.attrs!.level).toBe(2);
|
|
132
|
+
expect(heading.attrs!.textAlign).toBe('right');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('lists', () => {
|
|
137
|
+
test('should convert <ul><li> to bulletList with listItem children', () => {
|
|
138
|
+
const result = htmlToTiptap('<ul><li>Item 1</li><li>Item 2</li></ul>');
|
|
139
|
+
expect(result.content.length).toBe(1);
|
|
140
|
+
const list = result.content[0];
|
|
141
|
+
expect(list.type).toBe('bulletList');
|
|
142
|
+
expect(list.content!.length).toBe(2);
|
|
143
|
+
expect(list.content![0].type).toBe('listItem');
|
|
144
|
+
expect(list.content![1].type).toBe('listItem');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('should convert <ol><li> to orderedList', () => {
|
|
148
|
+
const result = htmlToTiptap('<ol><li>First</li><li>Second</li></ol>');
|
|
149
|
+
const list = result.content[0];
|
|
150
|
+
expect(list.type).toBe('orderedList');
|
|
151
|
+
expect(list.content!.length).toBe(2);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('should convert <ol start="3"> to orderedList with start attr', () => {
|
|
155
|
+
const result = htmlToTiptap('<ol start="3"><li>Third</li></ol>');
|
|
156
|
+
const list = result.content[0];
|
|
157
|
+
expect(list.type).toBe('orderedList');
|
|
158
|
+
expect(list.attrs!.start).toBe(3);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('should wrap list item inline content in paragraph', () => {
|
|
162
|
+
const result = htmlToTiptap('<ul><li>Simple text</li></ul>');
|
|
163
|
+
const listItem = result.content[0].content![0];
|
|
164
|
+
expect(listItem.type).toBe('listItem');
|
|
165
|
+
expect(listItem.content![0].type).toBe('paragraph');
|
|
166
|
+
expect(listItem.content![0].content![0].text).toBe('Simple text');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('should preserve block content inside list items', () => {
|
|
170
|
+
const result = htmlToTiptap('<ul><li><p>Block content</p></li></ul>');
|
|
171
|
+
const listItem = result.content[0].content![0];
|
|
172
|
+
expect(listItem.type).toBe('listItem');
|
|
173
|
+
expect(listItem.content![0].type).toBe('paragraph');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('should give empty list item an empty paragraph', () => {
|
|
177
|
+
const result = htmlToTiptap('<ul><li></li></ul>');
|
|
178
|
+
const listItem = result.content[0].content![0];
|
|
179
|
+
expect(listItem.type).toBe('listItem');
|
|
180
|
+
expect(listItem.content!.length).toBe(1);
|
|
181
|
+
expect(listItem.content![0].type).toBe('paragraph');
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('should convert <blockquote> to blockquote node', () => {
|
|
186
|
+
const result = htmlToTiptap('<blockquote>Quoted text</blockquote>');
|
|
187
|
+
const bq = result.content[0];
|
|
188
|
+
expect(bq.type).toBe('blockquote');
|
|
189
|
+
// Content should be wrapped in paragraph
|
|
190
|
+
expect(bq.content![0].type).toBe('paragraph');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('should convert empty <blockquote> to blockquote with empty paragraph', () => {
|
|
194
|
+
const result = htmlToTiptap('<blockquote></blockquote>');
|
|
195
|
+
const bq = result.content[0];
|
|
196
|
+
expect(bq.type).toBe('blockquote');
|
|
197
|
+
expect(bq.content!.length).toBe(1);
|
|
198
|
+
expect(bq.content![0].type).toBe('paragraph');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('should convert <pre><code> to codeBlock', () => {
|
|
202
|
+
const result = htmlToTiptap('<pre><code>const x = 1;</code></pre>');
|
|
203
|
+
const codeBlock = result.content[0];
|
|
204
|
+
expect(codeBlock.type).toBe('codeBlock');
|
|
205
|
+
expect(codeBlock.content![0].text).toBe('const x = 1;');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('should parse language from <code class="language-javascript">', () => {
|
|
209
|
+
const result = htmlToTiptap('<pre><code class="language-javascript">let y = 2;</code></pre>');
|
|
210
|
+
const codeBlock = result.content[0];
|
|
211
|
+
expect(codeBlock.type).toBe('codeBlock');
|
|
212
|
+
expect(codeBlock.attrs!.language).toBe('javascript');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('should handle <pre> without <code> child', () => {
|
|
216
|
+
const result = htmlToTiptap('<pre>Plain preformatted</pre>');
|
|
217
|
+
const codeBlock = result.content[0];
|
|
218
|
+
expect(codeBlock.type).toBe('codeBlock');
|
|
219
|
+
expect(codeBlock.content![0].text).toBe('Plain preformatted');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('should convert <hr> to horizontalRule', () => {
|
|
223
|
+
const result = htmlToTiptap('<p>Above</p><hr><p>Below</p>');
|
|
224
|
+
const hr = result.content[1];
|
|
225
|
+
expect(hr.type).toBe('horizontalRule');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('should convert <br> to hardBreak', () => {
|
|
229
|
+
const result = htmlToTiptap('<p>Line 1<br>Line 2</p>');
|
|
230
|
+
const p = result.content[0];
|
|
231
|
+
expect(p.type).toBe('paragraph');
|
|
232
|
+
// Should contain text, hardBreak, text
|
|
233
|
+
const types = p.content!.map((n) => n.type);
|
|
234
|
+
expect(types).toContain('hardBreak');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('should convert <img> to image node with attrs', () => {
|
|
238
|
+
const result = htmlToTiptap('<img src="/photo.jpg" alt="A photo" width="100" height="200">');
|
|
239
|
+
const img = findNode(result, 'image');
|
|
240
|
+
expect(img).toBeDefined();
|
|
241
|
+
expect(img!.attrs!.src).toBe('/photo.jpg');
|
|
242
|
+
expect(img!.attrs!.alt).toBe('A photo');
|
|
243
|
+
expect(img!.attrs!.width).toBe(100);
|
|
244
|
+
expect(img!.attrs!.height).toBe(200);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('should convert <img> with title attribute', () => {
|
|
248
|
+
const result = htmlToTiptap('<img src="/pic.png" title="My pic">');
|
|
249
|
+
const img = findNode(result, 'image');
|
|
250
|
+
expect(img!.attrs!.title).toBe('My pic');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('should convert <img> with only src', () => {
|
|
254
|
+
const result = htmlToTiptap('<img src="/minimal.png">');
|
|
255
|
+
const img = findNode(result, 'image');
|
|
256
|
+
expect(img!.attrs!.src).toBe('/minimal.png');
|
|
257
|
+
expect(img!.attrs!.alt).toBeUndefined();
|
|
258
|
+
expect(img!.attrs!.width).toBeUndefined();
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// -------------------------------------------------------------------------
|
|
263
|
+
// Inline elements (marks)
|
|
264
|
+
// -------------------------------------------------------------------------
|
|
265
|
+
describe('inline marks', () => {
|
|
266
|
+
test('should convert <strong> to bold mark', () => {
|
|
267
|
+
const result = htmlToTiptap('<p><strong>Bold text</strong></p>');
|
|
268
|
+
const textNode = result.content[0].content![0];
|
|
269
|
+
expect(textNode.text).toBe('Bold text');
|
|
270
|
+
expect(textNode.marks).toBeDefined();
|
|
271
|
+
expect(textNode.marks!.some((m) => m.type === 'bold')).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('should convert <b> to bold mark', () => {
|
|
275
|
+
const result = htmlToTiptap('<p><b>Bold text</b></p>');
|
|
276
|
+
const textNode = result.content[0].content![0];
|
|
277
|
+
expect(textNode.marks!.some((m) => m.type === 'bold')).toBe(true);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('should convert <em> to italic mark', () => {
|
|
281
|
+
const result = htmlToTiptap('<p><em>Italic text</em></p>');
|
|
282
|
+
const textNode = result.content[0].content![0];
|
|
283
|
+
expect(textNode.text).toBe('Italic text');
|
|
284
|
+
expect(textNode.marks!.some((m) => m.type === 'italic')).toBe(true);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('should convert <i> to italic mark', () => {
|
|
288
|
+
const result = htmlToTiptap('<p><i>Italic text</i></p>');
|
|
289
|
+
const textNode = result.content[0].content![0];
|
|
290
|
+
expect(textNode.marks!.some((m) => m.type === 'italic')).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('should convert <u> to underline mark', () => {
|
|
294
|
+
const result = htmlToTiptap('<p><u>Underlined</u></p>');
|
|
295
|
+
const textNode = result.content[0].content![0];
|
|
296
|
+
expect(textNode.marks!.some((m) => m.type === 'underline')).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('should convert <s> to strike mark', () => {
|
|
300
|
+
const result = htmlToTiptap('<p><s>Struck</s></p>');
|
|
301
|
+
const textNode = result.content[0].content![0];
|
|
302
|
+
expect(textNode.marks!.some((m) => m.type === 'strike')).toBe(true);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('should convert <del> to strike mark', () => {
|
|
306
|
+
const result = htmlToTiptap('<p><del>Deleted</del></p>');
|
|
307
|
+
const textNode = result.content[0].content![0];
|
|
308
|
+
expect(textNode.marks!.some((m) => m.type === 'strike')).toBe(true);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('should convert <code> to code mark', () => {
|
|
312
|
+
const result = htmlToTiptap('<p><code>inline code</code></p>');
|
|
313
|
+
const textNode = result.content[0].content![0];
|
|
314
|
+
expect(textNode.text).toBe('inline code');
|
|
315
|
+
expect(textNode.marks!.some((m) => m.type === 'code')).toBe(true);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('should convert <a> to text with link mark', () => {
|
|
319
|
+
const result = htmlToTiptap('<p><a href="https://example.com">Click here</a></p>');
|
|
320
|
+
const textNode = result.content[0].content![0];
|
|
321
|
+
expect(textNode.text).toBe('Click here');
|
|
322
|
+
const linkMark = textNode.marks!.find((m) => m.type === 'link');
|
|
323
|
+
expect(linkMark).toBeDefined();
|
|
324
|
+
expect(linkMark!.attrs!.href).toBe('https://example.com');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test('should convert <a> with target and rel attributes', () => {
|
|
328
|
+
const result = htmlToTiptap(
|
|
329
|
+
'<p><a href="/page" target="_blank" rel="noopener">Link</a></p>'
|
|
330
|
+
);
|
|
331
|
+
const textNode = result.content[0].content![0];
|
|
332
|
+
const linkMark = textNode.marks!.find((m) => m.type === 'link');
|
|
333
|
+
expect(linkMark!.attrs!.href).toBe('/page');
|
|
334
|
+
expect(linkMark!.attrs!.target).toBe('_blank');
|
|
335
|
+
expect(linkMark!.attrs!.rel).toBe('noopener');
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// -------------------------------------------------------------------------
|
|
340
|
+
// Nested marks
|
|
341
|
+
// -------------------------------------------------------------------------
|
|
342
|
+
describe('nested marks', () => {
|
|
343
|
+
test('should apply both bold and italic marks for <strong><em>', () => {
|
|
344
|
+
const result = htmlToTiptap('<p><strong><em>Bold and italic</em></strong></p>');
|
|
345
|
+
const textNode = result.content[0].content![0];
|
|
346
|
+
expect(textNode.text).toBe('Bold and italic');
|
|
347
|
+
expect(textNode.marks!.length).toBeGreaterThanOrEqual(2);
|
|
348
|
+
const markTypes = textNode.marks!.map((m) => m.type);
|
|
349
|
+
expect(markTypes).toContain('bold');
|
|
350
|
+
expect(markTypes).toContain('italic');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('should apply bold and italic for <em><strong> (reverse nesting)', () => {
|
|
354
|
+
const result = htmlToTiptap('<p><em><strong>Italic and bold</strong></em></p>');
|
|
355
|
+
const textNode = result.content[0].content![0];
|
|
356
|
+
const markTypes = textNode.marks!.map((m) => m.type);
|
|
357
|
+
expect(markTypes).toContain('bold');
|
|
358
|
+
expect(markTypes).toContain('italic');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('should handle bold inside link', () => {
|
|
362
|
+
const result = htmlToTiptap(
|
|
363
|
+
'<p><a href="/url"><strong>Bold link</strong></a></p>'
|
|
364
|
+
);
|
|
365
|
+
const textNode = result.content[0].content![0];
|
|
366
|
+
expect(textNode.text).toBe('Bold link');
|
|
367
|
+
const markTypes = textNode.marks!.map((m) => m.type);
|
|
368
|
+
expect(markTypes).toContain('bold');
|
|
369
|
+
expect(markTypes).toContain('link');
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// -------------------------------------------------------------------------
|
|
374
|
+
// Span handling
|
|
375
|
+
// -------------------------------------------------------------------------
|
|
376
|
+
describe('span handling', () => {
|
|
377
|
+
test('should convert <span data-meno-span="custom"> to textStyle mark', () => {
|
|
378
|
+
const result = htmlToTiptap('<p><span data-meno-span="custom">Styled</span></p>');
|
|
379
|
+
const textNode = result.content[0].content![0];
|
|
380
|
+
expect(textNode.text).toBe('Styled');
|
|
381
|
+
const styleMark = textNode.marks!.find((m) => m.type === 'textStyle');
|
|
382
|
+
expect(styleMark).toBeDefined();
|
|
383
|
+
expect(styleMark!.attrs!.class).toBe('custom');
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test('should pass through <span> without special attrs as just children', () => {
|
|
387
|
+
const result = htmlToTiptap('<p><span>Plain span</span></p>');
|
|
388
|
+
const textNode = result.content[0].content![0];
|
|
389
|
+
expect(textNode.type).toBe('text');
|
|
390
|
+
expect(textNode.text).toBe('Plain span');
|
|
391
|
+
// Should not have any marks (or at least no textStyle mark)
|
|
392
|
+
const hasTextStyle = textNode.marks?.some((m) => m.type === 'textStyle');
|
|
393
|
+
expect(hasTextStyle).toBeFalsy();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test('should handle <span> with style but no data-meno-span (returns children)', () => {
|
|
397
|
+
const result = htmlToTiptap('<p><span style="color: red">Red</span></p>');
|
|
398
|
+
const textNode = result.content[0].content![0];
|
|
399
|
+
expect(textNode.text).toBe('Red');
|
|
400
|
+
// Currently the implementation returns children without marks for styled spans
|
|
401
|
+
const hasTextStyle = textNode.marks?.some((m) => m.type === 'textStyle');
|
|
402
|
+
expect(hasTextStyle).toBeFalsy();
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// -------------------------------------------------------------------------
|
|
407
|
+
// Tables
|
|
408
|
+
// -------------------------------------------------------------------------
|
|
409
|
+
describe('tables', () => {
|
|
410
|
+
test('should convert a basic table with thead and tbody', () => {
|
|
411
|
+
const html = `
|
|
412
|
+
<table>
|
|
413
|
+
<thead>
|
|
414
|
+
<tr><th>Header 1</th><th>Header 2</th></tr>
|
|
415
|
+
</thead>
|
|
416
|
+
<tbody>
|
|
417
|
+
<tr><td>Cell 1</td><td>Cell 2</td></tr>
|
|
418
|
+
</tbody>
|
|
419
|
+
</table>
|
|
420
|
+
`;
|
|
421
|
+
const result = htmlToTiptap(html);
|
|
422
|
+
const table = findNode(result, 'table');
|
|
423
|
+
expect(table).toBeDefined();
|
|
424
|
+
expect(table!.content!.length).toBe(2); // 2 rows
|
|
425
|
+
|
|
426
|
+
// First row should have tableHeader cells
|
|
427
|
+
const headerRow = table!.content![0];
|
|
428
|
+
expect(headerRow.type).toBe('tableRow');
|
|
429
|
+
expect(headerRow.content![0].type).toBe('tableHeader');
|
|
430
|
+
expect(headerRow.content![1].type).toBe('tableHeader');
|
|
431
|
+
|
|
432
|
+
// Second row should have tableCell cells
|
|
433
|
+
const bodyRow = table!.content![1];
|
|
434
|
+
expect(bodyRow.type).toBe('tableRow');
|
|
435
|
+
expect(bodyRow.content![0].type).toBe('tableCell');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test('should handle table cells with colspan and rowspan', () => {
|
|
439
|
+
const html = '<table><tbody><tr><td colspan="2" rowspan="3">Wide cell</td></tr></tbody></table>';
|
|
440
|
+
const result = htmlToTiptap(html);
|
|
441
|
+
const cell = findNode(result, 'tableCell');
|
|
442
|
+
expect(cell).toBeDefined();
|
|
443
|
+
expect(cell!.attrs!.colspan).toBe(2);
|
|
444
|
+
expect(cell!.attrs!.rowspan).toBe(3);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test('should handle direct <tr> children without thead/tbody', () => {
|
|
448
|
+
const html = '<table><tr><td>Direct</td></tr></table>';
|
|
449
|
+
const result = htmlToTiptap(html);
|
|
450
|
+
const table = findNode(result, 'table');
|
|
451
|
+
expect(table).toBeDefined();
|
|
452
|
+
expect(table!.content!.length).toBe(1);
|
|
453
|
+
const row = table!.content![0];
|
|
454
|
+
expect(row.type).toBe('tableRow');
|
|
455
|
+
expect(row.content![0].type).toBe('tableCell');
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test('should give empty table cells an empty paragraph', () => {
|
|
459
|
+
const html = '<table><tbody><tr><td></td></tr></tbody></table>';
|
|
460
|
+
const result = htmlToTiptap(html);
|
|
461
|
+
const cell = findNode(result, 'tableCell');
|
|
462
|
+
expect(cell).toBeDefined();
|
|
463
|
+
expect(cell!.content!.length).toBe(1);
|
|
464
|
+
expect(cell!.content![0].type).toBe('paragraph');
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test('should handle <th> cells in thead as tableHeader', () => {
|
|
468
|
+
const html = '<table><thead><tr><th>H1</th></tr></thead></table>';
|
|
469
|
+
const result = htmlToTiptap(html);
|
|
470
|
+
const header = findNode(result, 'tableHeader');
|
|
471
|
+
expect(header).toBeDefined();
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test('should treat <td> in thead as tableHeader (isHeader flag)', () => {
|
|
475
|
+
const html = '<table><thead><tr><td>Header-like</td></tr></thead></table>';
|
|
476
|
+
const result = htmlToTiptap(html);
|
|
477
|
+
// parseTableRow is called with isHeader=true for thead, so td becomes tableHeader
|
|
478
|
+
const header = findNode(result, 'tableHeader');
|
|
479
|
+
expect(header).toBeDefined();
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// -------------------------------------------------------------------------
|
|
484
|
+
// Container elements
|
|
485
|
+
// -------------------------------------------------------------------------
|
|
486
|
+
describe('container elements', () => {
|
|
487
|
+
test('should parse <div> children without wrapping node', () => {
|
|
488
|
+
const result = htmlToTiptap('<div><p>Inside div</p></div>');
|
|
489
|
+
// The div should not produce a wrapping node; its children (the <p>) should be at top level
|
|
490
|
+
expect(result.content[0].type).toBe('paragraph');
|
|
491
|
+
expect(result.content[0].content![0].text).toBe('Inside div');
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test('should parse <article> children without wrapping node', () => {
|
|
495
|
+
const result = htmlToTiptap('<article><p>Article content</p></article>');
|
|
496
|
+
expect(result.content[0].type).toBe('paragraph');
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test('should parse <section> children without wrapping node', () => {
|
|
500
|
+
const result = htmlToTiptap('<section><p>Section content</p></section>');
|
|
501
|
+
expect(result.content[0].type).toBe('paragraph');
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test('should parse <main> children without wrapping node', () => {
|
|
505
|
+
const result = htmlToTiptap('<main><p>Main content</p></main>');
|
|
506
|
+
expect(result.content[0].type).toBe('paragraph');
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test('should parse <header> children without wrapping node', () => {
|
|
510
|
+
const result = htmlToTiptap('<header><h1>Title</h1></header>');
|
|
511
|
+
expect(result.content[0].type).toBe('heading');
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test('should parse <footer> children without wrapping node', () => {
|
|
515
|
+
const result = htmlToTiptap('<footer><p>Footer</p></footer>');
|
|
516
|
+
expect(result.content[0].type).toBe('paragraph');
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// -------------------------------------------------------------------------
|
|
521
|
+
// Inline content wrapping
|
|
522
|
+
// -------------------------------------------------------------------------
|
|
523
|
+
describe('inline content wrapping', () => {
|
|
524
|
+
test('should wrap top-level text in a paragraph', () => {
|
|
525
|
+
const result = htmlToTiptap('Just text');
|
|
526
|
+
expect(result.content.length).toBe(1);
|
|
527
|
+
expect(result.content[0].type).toBe('paragraph');
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test('should wrap mixed inline and block siblings correctly', () => {
|
|
531
|
+
// Inline text followed by a block element and more inline text
|
|
532
|
+
const result = htmlToTiptap('Before<p>Block</p>After');
|
|
533
|
+
// 'Before' should be wrapped in a paragraph, then the <p>, then 'After' in a paragraph
|
|
534
|
+
expect(result.content.length).toBe(3);
|
|
535
|
+
expect(result.content[0].type).toBe('paragraph');
|
|
536
|
+
expect(result.content[0].content![0].text).toBe('Before');
|
|
537
|
+
expect(result.content[1].type).toBe('paragraph');
|
|
538
|
+
expect(result.content[1].content![0].text).toBe('Block');
|
|
539
|
+
expect(result.content[2].type).toBe('paragraph');
|
|
540
|
+
expect(result.content[2].content![0].text).toBe('After');
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test('should group consecutive inline elements into one paragraph', () => {
|
|
544
|
+
const result = htmlToTiptap('<strong>Bold</strong> and <em>italic</em>');
|
|
545
|
+
// All inline content should end up in a single paragraph
|
|
546
|
+
expect(result.content.length).toBe(1);
|
|
547
|
+
expect(result.content[0].type).toBe('paragraph');
|
|
548
|
+
// Should have multiple text nodes inside
|
|
549
|
+
expect(result.content[0].content!.length).toBeGreaterThanOrEqual(2);
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// -------------------------------------------------------------------------
|
|
554
|
+
// Multiple paragraphs and mixed content
|
|
555
|
+
// -------------------------------------------------------------------------
|
|
556
|
+
describe('complex content', () => {
|
|
557
|
+
test('should convert multiple paragraphs', () => {
|
|
558
|
+
const result = htmlToTiptap('<p>First</p><p>Second</p><p>Third</p>');
|
|
559
|
+
expect(result.content.length).toBe(3);
|
|
560
|
+
expect(result.content[0].content![0].text).toBe('First');
|
|
561
|
+
expect(result.content[1].content![0].text).toBe('Second');
|
|
562
|
+
expect(result.content[2].content![0].text).toBe('Third');
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test('should convert paragraph with mixed text and marks', () => {
|
|
566
|
+
const result = htmlToTiptap('<p>Normal <strong>bold</strong> normal</p>');
|
|
567
|
+
const p = result.content[0];
|
|
568
|
+
expect(p.type).toBe('paragraph');
|
|
569
|
+
expect(p.content!.length).toBe(3);
|
|
570
|
+
expect(p.content![0].text).toBe('Normal ');
|
|
571
|
+
expect(p.content![0].marks).toBeUndefined();
|
|
572
|
+
expect(p.content![1].text).toBe('bold');
|
|
573
|
+
expect(p.content![1].marks![0].type).toBe('bold');
|
|
574
|
+
expect(p.content![2].text).toBe(' normal');
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test('should handle empty paragraph', () => {
|
|
578
|
+
const result = htmlToTiptap('<p></p>');
|
|
579
|
+
expect(result.content.length).toBe(1);
|
|
580
|
+
const p = result.content[0];
|
|
581
|
+
expect(p.type).toBe('paragraph');
|
|
582
|
+
// Empty paragraph should not have content property (or have undefined content)
|
|
583
|
+
expect(p.content).toBeUndefined();
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test('should handle heading followed by paragraph', () => {
|
|
587
|
+
const result = htmlToTiptap('<h1>Title</h1><p>Body text</p>');
|
|
588
|
+
expect(result.content.length).toBe(2);
|
|
589
|
+
expect(result.content[0].type).toBe('heading');
|
|
590
|
+
expect(result.content[1].type).toBe('paragraph');
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test('should handle blockquote containing paragraph', () => {
|
|
594
|
+
const result = htmlToTiptap('<blockquote><p>Quoted paragraph</p></blockquote>');
|
|
595
|
+
const bq = result.content[0];
|
|
596
|
+
expect(bq.type).toBe('blockquote');
|
|
597
|
+
expect(bq.content![0].type).toBe('paragraph');
|
|
598
|
+
expect(bq.content![0].content![0].text).toBe('Quoted paragraph');
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
// -------------------------------------------------------------------------
|
|
603
|
+
// Unknown elements
|
|
604
|
+
// -------------------------------------------------------------------------
|
|
605
|
+
describe('unknown elements', () => {
|
|
606
|
+
test('should parse children of unknown elements', () => {
|
|
607
|
+
const result = htmlToTiptap('<custom-element><p>Content</p></custom-element>');
|
|
608
|
+
// Unknown element should have its children parsed
|
|
609
|
+
expect(result.content[0].type).toBe('paragraph');
|
|
610
|
+
expect(result.content[0].content![0].text).toBe('Content');
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
// -------------------------------------------------------------------------
|
|
615
|
+
// Document structure validation
|
|
616
|
+
// -------------------------------------------------------------------------
|
|
617
|
+
describe('document structure', () => {
|
|
618
|
+
test('should always return type "doc"', () => {
|
|
619
|
+
expect(htmlToTiptap('').type).toBe('doc');
|
|
620
|
+
expect(htmlToTiptap('<p>test</p>').type).toBe('doc');
|
|
621
|
+
expect(htmlToTiptap(null as any).type).toBe('doc');
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test('should always return content as an array', () => {
|
|
625
|
+
expect(Array.isArray(htmlToTiptap('').content)).toBe(true);
|
|
626
|
+
expect(Array.isArray(htmlToTiptap('<p>test</p>').content)).toBe(true);
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
// -------------------------------------------------------------------------
|
|
631
|
+
// Code block edge cases
|
|
632
|
+
// -------------------------------------------------------------------------
|
|
633
|
+
describe('code block edge cases', () => {
|
|
634
|
+
test('should handle <pre><code> with no language class', () => {
|
|
635
|
+
const result = htmlToTiptap('<pre><code>plain code</code></pre>');
|
|
636
|
+
const codeBlock = result.content[0];
|
|
637
|
+
expect(codeBlock.type).toBe('codeBlock');
|
|
638
|
+
expect(codeBlock.attrs?.language).toBeUndefined();
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test('should handle <pre><code class="language-python">', () => {
|
|
642
|
+
const result = htmlToTiptap('<pre><code class="language-python">print("hi")</code></pre>');
|
|
643
|
+
const codeBlock = result.content[0];
|
|
644
|
+
expect(codeBlock.attrs!.language).toBe('python');
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test('should handle empty code block', () => {
|
|
648
|
+
const result = htmlToTiptap('<pre><code></code></pre>');
|
|
649
|
+
const codeBlock = result.content[0];
|
|
650
|
+
expect(codeBlock.type).toBe('codeBlock');
|
|
651
|
+
expect(codeBlock.content).toEqual([]);
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
// -------------------------------------------------------------------------
|
|
656
|
+
// Image edge cases
|
|
657
|
+
// -------------------------------------------------------------------------
|
|
658
|
+
describe('image edge cases', () => {
|
|
659
|
+
test('should handle <img> with empty src', () => {
|
|
660
|
+
const result = htmlToTiptap('<img src="">');
|
|
661
|
+
const img = findNode(result, 'image');
|
|
662
|
+
expect(img).toBeDefined();
|
|
663
|
+
expect(img!.attrs!.src).toBe('');
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test('should handle <img> without src attribute', () => {
|
|
667
|
+
const result = htmlToTiptap('<img alt="no source">');
|
|
668
|
+
const img = findNode(result, 'image');
|
|
669
|
+
expect(img).toBeDefined();
|
|
670
|
+
// getAttribute('src') returns null, fallback is ''
|
|
671
|
+
expect(img!.attrs!.src).toBe('');
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
// -------------------------------------------------------------------------
|
|
676
|
+
// Link edge cases
|
|
677
|
+
// -------------------------------------------------------------------------
|
|
678
|
+
describe('link edge cases', () => {
|
|
679
|
+
test('should handle <a> without href', () => {
|
|
680
|
+
const result = htmlToTiptap('<p><a>No href</a></p>');
|
|
681
|
+
const textNode = result.content[0].content![0];
|
|
682
|
+
const linkMark = textNode.marks!.find((m) => m.type === 'link');
|
|
683
|
+
expect(linkMark).toBeDefined();
|
|
684
|
+
expect(linkMark!.attrs!.href).toBe('');
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
test('should handle <a> with only href (no target or rel)', () => {
|
|
688
|
+
const result = htmlToTiptap('<p><a href="/page">Link</a></p>');
|
|
689
|
+
const textNode = result.content[0].content![0];
|
|
690
|
+
const linkMark = textNode.marks!.find((m) => m.type === 'link');
|
|
691
|
+
expect(linkMark!.attrs!.href).toBe('/page');
|
|
692
|
+
expect(linkMark!.attrs!.target).toBeUndefined();
|
|
693
|
+
expect(linkMark!.attrs!.rel).toBeUndefined();
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
// -------------------------------------------------------------------------
|
|
698
|
+
// Horizontal rule as standalone
|
|
699
|
+
// -------------------------------------------------------------------------
|
|
700
|
+
describe('horizontal rule', () => {
|
|
701
|
+
test('should convert standalone <hr>', () => {
|
|
702
|
+
const result = htmlToTiptap('<hr>');
|
|
703
|
+
const hr = findNode(result, 'horizontalRule');
|
|
704
|
+
expect(hr).toBeDefined();
|
|
705
|
+
expect(hr!.type).toBe('horizontalRule');
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
test('should handle <hr> between paragraphs', () => {
|
|
709
|
+
const result = htmlToTiptap('<p>Above</p><hr><p>Below</p>');
|
|
710
|
+
expect(result.content.length).toBe(3);
|
|
711
|
+
expect(result.content[0].type).toBe('paragraph');
|
|
712
|
+
expect(result.content[1].type).toBe('horizontalRule');
|
|
713
|
+
expect(result.content[2].type).toBe('paragraph');
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
// -------------------------------------------------------------------------
|
|
718
|
+
// Nested lists
|
|
719
|
+
// -------------------------------------------------------------------------
|
|
720
|
+
describe('nested lists', () => {
|
|
721
|
+
test('should handle nested <ul> inside <li>', () => {
|
|
722
|
+
const html = '<ul><li>Parent<ul><li>Child</li></ul></li></ul>';
|
|
723
|
+
const result = htmlToTiptap(html);
|
|
724
|
+
const outerList = result.content[0];
|
|
725
|
+
expect(outerList.type).toBe('bulletList');
|
|
726
|
+
const listItem = outerList.content![0];
|
|
727
|
+
expect(listItem.type).toBe('listItem');
|
|
728
|
+
// The list item should contain both a paragraph (for "Parent") and a nested bulletList
|
|
729
|
+
const nestedList = listItem.content!.find((n) => n.type === 'bulletList');
|
|
730
|
+
expect(nestedList).toBeDefined();
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
// -------------------------------------------------------------------------
|
|
735
|
+
// Whitespace handling
|
|
736
|
+
// -------------------------------------------------------------------------
|
|
737
|
+
describe('whitespace handling', () => {
|
|
738
|
+
test('should preserve meaningful whitespace in text', () => {
|
|
739
|
+
const result = htmlToTiptap('<p>Hello World</p>');
|
|
740
|
+
expect(result.content[0].content![0].text).toBe('Hello World');
|
|
741
|
+
});
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
// -------------------------------------------------------------------------
|
|
745
|
+
// Server-side fallback (no DOMParser)
|
|
746
|
+
// -------------------------------------------------------------------------
|
|
747
|
+
describe('server-side fallback (no DOMParser)', () => {
|
|
748
|
+
test('should fall back to stripping HTML when DOMParser is unavailable', () => {
|
|
749
|
+
const originalDOMParser = (globalThis as any).DOMParser;
|
|
750
|
+
delete (globalThis as any).DOMParser;
|
|
751
|
+
try {
|
|
752
|
+
const result = htmlToTiptap('<p>Hello <strong>world</strong></p>');
|
|
753
|
+
expect(result.type).toBe('doc');
|
|
754
|
+
expect(result.content.length).toBe(1);
|
|
755
|
+
expect(result.content[0].type).toBe('paragraph');
|
|
756
|
+
// Should strip HTML tags and return plain text
|
|
757
|
+
expect(result.content[0].content![0].type).toBe('text');
|
|
758
|
+
expect(result.content[0].content![0].text).toBe('Hello world');
|
|
759
|
+
} finally {
|
|
760
|
+
(globalThis as any).DOMParser = originalDOMParser;
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test('should handle plain text without DOMParser', () => {
|
|
765
|
+
const originalDOMParser = (globalThis as any).DOMParser;
|
|
766
|
+
delete (globalThis as any).DOMParser;
|
|
767
|
+
try {
|
|
768
|
+
const result = htmlToTiptap('Just plain text');
|
|
769
|
+
expect(result.content[0].content![0].text).toBe('Just plain text');
|
|
770
|
+
} finally {
|
|
771
|
+
(globalThis as any).DOMParser = originalDOMParser;
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
// -------------------------------------------------------------------------
|
|
777
|
+
// Non-element node types (comment nodes, etc.)
|
|
778
|
+
// -------------------------------------------------------------------------
|
|
779
|
+
describe('non-element node types', () => {
|
|
780
|
+
test('should ignore HTML comments', () => {
|
|
781
|
+
const result = htmlToTiptap('<p>Before<!-- comment -->After</p>');
|
|
782
|
+
const p = result.content[0];
|
|
783
|
+
expect(p.type).toBe('paragraph');
|
|
784
|
+
// Comment node should be ignored, only text nodes remain
|
|
785
|
+
const textNodes = p.content!.filter((n) => n.type === 'text');
|
|
786
|
+
expect(textNodes.length).toBeGreaterThanOrEqual(1);
|
|
787
|
+
const fullText = textNodes.map((n) => n.text).join('');
|
|
788
|
+
expect(fullText).toContain('Before');
|
|
789
|
+
expect(fullText).toContain('After');
|
|
790
|
+
});
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
// -------------------------------------------------------------------------
|
|
794
|
+
// Standalone table elements via parseNode switch
|
|
795
|
+
// -------------------------------------------------------------------------
|
|
796
|
+
describe('table elements parsed standalone via parseNode', () => {
|
|
797
|
+
test('should handle <strike> tag as strike mark', () => {
|
|
798
|
+
const result = htmlToTiptap('<p><strike>Strikethrough</strike></p>');
|
|
799
|
+
const textNode = result.content[0].content![0];
|
|
800
|
+
expect(textNode.marks!.some((m) => m.type === 'strike')).toBe(true);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
test('should handle table with tfoot section', () => {
|
|
804
|
+
const html = `
|
|
805
|
+
<table>
|
|
806
|
+
<thead><tr><th>Head</th></tr></thead>
|
|
807
|
+
<tbody><tr><td>Body</td></tr></tbody>
|
|
808
|
+
<tfoot><tr><td>Foot</td></tr></tfoot>
|
|
809
|
+
</table>
|
|
810
|
+
`;
|
|
811
|
+
const result = htmlToTiptap(html);
|
|
812
|
+
const table = findNode(result, 'table');
|
|
813
|
+
expect(table).toBeDefined();
|
|
814
|
+
// Should have 3 rows: head, body, foot
|
|
815
|
+
expect(table!.content!.length).toBe(3);
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
test('should handle table with both section rows and direct tr children', () => {
|
|
819
|
+
// Note: happy-dom may restructure the HTML, moving direct <tr> into <tbody>.
|
|
820
|
+
// The key test is that parseTable handles both cases.
|
|
821
|
+
const html = `
|
|
822
|
+
<table>
|
|
823
|
+
<tbody><tr><td>Section row</td></tr></tbody>
|
|
824
|
+
<tr><td>Direct row</td></tr>
|
|
825
|
+
</table>
|
|
826
|
+
`;
|
|
827
|
+
const result = htmlToTiptap(html);
|
|
828
|
+
const table = findNode(result, 'table');
|
|
829
|
+
expect(table).toBeDefined();
|
|
830
|
+
// At minimum, we should get at least 1 row
|
|
831
|
+
expect(table!.content!.length).toBeGreaterThanOrEqual(1);
|
|
832
|
+
});
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
// -------------------------------------------------------------------------
|
|
836
|
+
// addMarkToNodes recursive path (non-text node with content inside marks)
|
|
837
|
+
// -------------------------------------------------------------------------
|
|
838
|
+
describe('marks on non-text nodes', () => {
|
|
839
|
+
test('should apply mark recursively to nested inline content', () => {
|
|
840
|
+
// <strong> wrapping a <br> and text - hardBreak is a non-text node
|
|
841
|
+
const result = htmlToTiptap('<p><strong>Before<br>After</strong></p>');
|
|
842
|
+
const p = result.content[0];
|
|
843
|
+
expect(p.type).toBe('paragraph');
|
|
844
|
+
// Should have text nodes with bold mark and a hardBreak
|
|
845
|
+
const boldTexts = p.content!.filter(
|
|
846
|
+
(n) => n.type === 'text' && n.marks?.some((m) => m.type === 'bold')
|
|
847
|
+
);
|
|
848
|
+
expect(boldTexts.length).toBeGreaterThanOrEqual(1);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
test('should handle bold link (mark applied to non-text node with content)', () => {
|
|
852
|
+
// <strong> wrapping an <a> -- parseInlineWithMark returns nodes from the <a>,
|
|
853
|
+
// and addMarkToNodes must handle the case where a node has content (the link text)
|
|
854
|
+
// but is not a text node itself. However, parseLink returns text nodes with link mark,
|
|
855
|
+
// so the bold mark is applied to text nodes that already have marks.
|
|
856
|
+
const result = htmlToTiptap('<p><strong><a href="/url">Link text</a></strong></p>');
|
|
857
|
+
const p = result.content[0];
|
|
858
|
+
const textNode = p.content![0];
|
|
859
|
+
expect(textNode.type).toBe('text');
|
|
860
|
+
expect(textNode.text).toBe('Link text');
|
|
861
|
+
const markTypes = textNode.marks!.map((m) => m.type);
|
|
862
|
+
expect(markTypes).toContain('bold');
|
|
863
|
+
expect(markTypes).toContain('link');
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
// -------------------------------------------------------------------------
|
|
868
|
+
// Standalone table elements (tr, td, th outside table)
|
|
869
|
+
// -------------------------------------------------------------------------
|
|
870
|
+
describe('standalone table elements outside table context', () => {
|
|
871
|
+
test('should handle standalone <tr> inside a div', () => {
|
|
872
|
+
// This should trigger the 'tr' case in parseNode switch (line 207-208)
|
|
873
|
+
const result = htmlToTiptap('<div><tr><td>Cell</td></tr></div>');
|
|
874
|
+
// The tr is parsed via parseTableRow -> produces tableRow
|
|
875
|
+
const tableRow = findNode(result, 'tableRow');
|
|
876
|
+
// Depending on happy-dom's handling, the <tr> may or may not survive
|
|
877
|
+
// But parseNode should handle it
|
|
878
|
+
expect(result.type).toBe('doc');
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
test('should handle standalone <td> inside a div', () => {
|
|
882
|
+
// This triggers the 'td' case in parseNode switch (line 209-210)
|
|
883
|
+
const result = htmlToTiptap('<div><td>Cell content</td></div>');
|
|
884
|
+
expect(result.type).toBe('doc');
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
test('should handle standalone <th> inside a div', () => {
|
|
888
|
+
// This triggers the 'th' case in parseNode switch (line 211-212)
|
|
889
|
+
const result = htmlToTiptap('<div><th>Header content</th></div>');
|
|
890
|
+
expect(result.type).toBe('doc');
|
|
891
|
+
});
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
// -------------------------------------------------------------------------
|
|
895
|
+
// Empty heading
|
|
896
|
+
// -------------------------------------------------------------------------
|
|
897
|
+
describe('empty heading', () => {
|
|
898
|
+
test('should handle empty heading', () => {
|
|
899
|
+
const result = htmlToTiptap('<h1></h1>');
|
|
900
|
+
const heading = result.content[0];
|
|
901
|
+
expect(heading.type).toBe('heading');
|
|
902
|
+
expect(heading.attrs!.level).toBe(1);
|
|
903
|
+
expect(heading.content).toBeUndefined();
|
|
904
|
+
});
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
// -------------------------------------------------------------------------
|
|
908
|
+
// Edge case: content that produces empty after parsing
|
|
909
|
+
// -------------------------------------------------------------------------
|
|
910
|
+
describe('edge cases for empty content paths', () => {
|
|
911
|
+
test('should handle HTML with only a comment (no real content)', () => {
|
|
912
|
+
// Comments may be stripped by happy-dom, potentially yielding empty content
|
|
913
|
+
const result = htmlToTiptap('<!-- only a comment -->');
|
|
914
|
+
expect(result.type).toBe('doc');
|
|
915
|
+
// Either empty doc or paragraph wrapping empty text -- both are valid
|
|
916
|
+
expect(Array.isArray(result.content)).toBe(true);
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
test('should handle numeric input coerced to string context', () => {
|
|
920
|
+
// The guard checks typeof html !== 'string'
|
|
921
|
+
const result = htmlToTiptap(42 as any);
|
|
922
|
+
expect(result).toEqual({ type: 'doc', content: [] });
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
test('should handle boolean input', () => {
|
|
926
|
+
const result = htmlToTiptap(true as any);
|
|
927
|
+
expect(result).toEqual({ type: 'doc', content: [] });
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
test('should handle object input', () => {
|
|
931
|
+
const result = htmlToTiptap({} as any);
|
|
932
|
+
expect(result).toEqual({ type: 'doc', content: [] });
|
|
933
|
+
});
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
// -------------------------------------------------------------------------
|
|
937
|
+
// Standalone <li> outside of list (triggers parseNode li path directly)
|
|
938
|
+
// -------------------------------------------------------------------------
|
|
939
|
+
describe('standalone list item', () => {
|
|
940
|
+
test('should handle <li> wrapped in a non-list container (div)', () => {
|
|
941
|
+
// When a <li> is a child of a div, parseNode encounters it directly
|
|
942
|
+
const result = htmlToTiptap('<div><li>Item outside list</li></div>');
|
|
943
|
+
const listItem = findNode(result, 'listItem');
|
|
944
|
+
expect(listItem).toBeDefined();
|
|
945
|
+
expect(listItem!.content![0].type).toBe('paragraph');
|
|
946
|
+
});
|
|
947
|
+
});
|
|
948
|
+
});
|