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.
Files changed (59) hide show
  1. package/build-static.test.ts +424 -0
  2. package/build-static.ts +100 -13
  3. package/lib/client/ClientInitializer.ts +4 -0
  4. package/lib/client/core/ComponentBuilder.ts +155 -16
  5. package/lib/client/core/builders/embedBuilder.ts +48 -6
  6. package/lib/client/core/builders/linkBuilder.ts +2 -2
  7. package/lib/client/core/builders/linkNodeBuilder.ts +45 -5
  8. package/lib/client/core/builders/listBuilder.ts +12 -3
  9. package/lib/client/routing/Router.tsx +8 -1
  10. package/lib/client/templateEngine.ts +89 -98
  11. package/lib/server/__integration__/api-routes.test.ts +148 -0
  12. package/lib/server/__integration__/cms-integration.test.ts +161 -0
  13. package/lib/server/__integration__/server-lifecycle.test.ts +101 -0
  14. package/lib/server/__integration__/ssr-rendering.test.ts +131 -0
  15. package/lib/server/__integration__/static-assets.test.ts +80 -0
  16. package/lib/server/__integration__/test-helpers.ts +205 -0
  17. package/lib/server/ab/generateFunctions.ts +346 -0
  18. package/lib/server/ab/trackingScript.ts +45 -0
  19. package/lib/server/index.ts +2 -2
  20. package/lib/server/jsonLoader.ts +124 -46
  21. package/lib/server/routes/api/cms.ts +3 -2
  22. package/lib/server/routes/api/components.ts +13 -2
  23. package/lib/server/services/cmsService.ts +0 -5
  24. package/lib/server/services/componentService.ts +255 -29
  25. package/lib/server/services/configService.test.ts +950 -0
  26. package/lib/server/services/configService.ts +39 -0
  27. package/lib/server/services/index.ts +1 -1
  28. package/lib/server/ssr/htmlGenerator.test.ts +992 -0
  29. package/lib/server/ssr/htmlGenerator.ts +3 -3
  30. package/lib/server/ssr/imageMetadata.test.ts +168 -0
  31. package/lib/server/ssr/imageMetadata.ts +58 -0
  32. package/lib/server/ssr/jsCollector.test.ts +287 -0
  33. package/lib/server/ssr/ssrRenderer.test.ts +3702 -0
  34. package/lib/server/ssr/ssrRenderer.ts +131 -15
  35. package/lib/shared/constants.ts +3 -0
  36. package/lib/shared/fontLoader.test.ts +335 -0
  37. package/lib/shared/i18n.test.ts +106 -0
  38. package/lib/shared/i18n.ts +17 -11
  39. package/lib/shared/index.ts +3 -0
  40. package/lib/shared/itemTemplateUtils.ts +43 -1
  41. package/lib/shared/libraryLoader.test.ts +392 -0
  42. package/lib/shared/linkUtils.ts +24 -0
  43. package/lib/shared/nodeUtils.test.ts +100 -0
  44. package/lib/shared/nodeUtils.ts +43 -0
  45. package/lib/shared/registry/NodeTypeDefinition.ts +2 -2
  46. package/lib/shared/registry/nodeTypes/ListNodeType.ts +20 -2
  47. package/lib/shared/richtext/htmlToTiptap.test.ts +948 -0
  48. package/lib/shared/richtext/htmlToTiptap.ts +46 -2
  49. package/lib/shared/richtext/tiptapToHtml.ts +65 -0
  50. package/lib/shared/richtext/types.ts +4 -1
  51. package/lib/shared/types/cms.ts +2 -0
  52. package/lib/shared/types/components.ts +12 -3
  53. package/lib/shared/types/experiments.ts +55 -0
  54. package/lib/shared/types/index.ts +10 -0
  55. package/lib/shared/utils.ts +2 -6
  56. package/lib/shared/validation/propValidator.test.ts +50 -0
  57. package/lib/shared/validation/propValidator.ts +2 -2
  58. package/lib/shared/validation/schemas.ts +10 -2
  59. 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
+ });