@pilotiq/tiptap 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +67 -0
  3. package/dist/Block.d.ts +47 -0
  4. package/dist/Block.d.ts.map +1 -0
  5. package/dist/Block.js +56 -0
  6. package/dist/Block.js.map +1 -0
  7. package/dist/MentionProvider.d.ts +97 -0
  8. package/dist/MentionProvider.d.ts.map +1 -0
  9. package/dist/MentionProvider.js +104 -0
  10. package/dist/MentionProvider.js.map +1 -0
  11. package/dist/RichTextField.d.ts +286 -0
  12. package/dist/RichTextField.d.ts.map +1 -0
  13. package/dist/RichTextField.js +369 -0
  14. package/dist/RichTextField.js.map +1 -0
  15. package/dist/extensions/BlockNodeExtension.d.ts +41 -0
  16. package/dist/extensions/BlockNodeExtension.d.ts.map +1 -0
  17. package/dist/extensions/BlockNodeExtension.js +103 -0
  18. package/dist/extensions/BlockNodeExtension.js.map +1 -0
  19. package/dist/extensions/DragHandleExtension.d.ts +19 -0
  20. package/dist/extensions/DragHandleExtension.d.ts.map +1 -0
  21. package/dist/extensions/DragHandleExtension.js +166 -0
  22. package/dist/extensions/DragHandleExtension.js.map +1 -0
  23. package/dist/extensions/GridExtension.d.ts +49 -0
  24. package/dist/extensions/GridExtension.d.ts.map +1 -0
  25. package/dist/extensions/GridExtension.js +105 -0
  26. package/dist/extensions/GridExtension.js.map +1 -0
  27. package/dist/extensions/MentionExtension.d.ts +71 -0
  28. package/dist/extensions/MentionExtension.d.ts.map +1 -0
  29. package/dist/extensions/MentionExtension.js +165 -0
  30. package/dist/extensions/MentionExtension.js.map +1 -0
  31. package/dist/extensions/MergeTagExtension.d.ts +24 -0
  32. package/dist/extensions/MergeTagExtension.d.ts.map +1 -0
  33. package/dist/extensions/MergeTagExtension.js +57 -0
  34. package/dist/extensions/MergeTagExtension.js.map +1 -0
  35. package/dist/extensions/SlashCommandExtension.d.ts +71 -0
  36. package/dist/extensions/SlashCommandExtension.d.ts.map +1 -0
  37. package/dist/extensions/SlashCommandExtension.js +244 -0
  38. package/dist/extensions/SlashCommandExtension.js.map +1 -0
  39. package/dist/extensions/TextSizeMarks.d.ts +33 -0
  40. package/dist/extensions/TextSizeMarks.d.ts.map +1 -0
  41. package/dist/extensions/TextSizeMarks.js +47 -0
  42. package/dist/extensions/TextSizeMarks.js.map +1 -0
  43. package/dist/index.d.ts +8 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +8 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/plugin.d.ts +18 -0
  48. package/dist/plugin.d.ts.map +1 -0
  49. package/dist/plugin.js +25 -0
  50. package/dist/plugin.js.map +1 -0
  51. package/dist/react/BlockNodeView.d.ts +19 -0
  52. package/dist/react/BlockNodeView.d.ts.map +1 -0
  53. package/dist/react/BlockNodeView.js +60 -0
  54. package/dist/react/BlockNodeView.js.map +1 -0
  55. package/dist/react/BlockSidePanel.d.ts +105 -0
  56. package/dist/react/BlockSidePanel.d.ts.map +1 -0
  57. package/dist/react/BlockSidePanel.js +339 -0
  58. package/dist/react/BlockSidePanel.js.map +1 -0
  59. package/dist/react/FloatingToolbar.d.ts +13 -0
  60. package/dist/react/FloatingToolbar.d.ts.map +1 -0
  61. package/dist/react/FloatingToolbar.js +113 -0
  62. package/dist/react/FloatingToolbar.js.map +1 -0
  63. package/dist/react/MentionMenu.d.ts +26 -0
  64. package/dist/react/MentionMenu.d.ts.map +1 -0
  65. package/dist/react/MentionMenu.js +64 -0
  66. package/dist/react/MentionMenu.js.map +1 -0
  67. package/dist/react/Palette.d.ts +26 -0
  68. package/dist/react/Palette.d.ts.map +1 -0
  69. package/dist/react/Palette.js +21 -0
  70. package/dist/react/Palette.js.map +1 -0
  71. package/dist/react/SlashMenu.d.ts +24 -0
  72. package/dist/react/SlashMenu.d.ts.map +1 -0
  73. package/dist/react/SlashMenu.js +74 -0
  74. package/dist/react/SlashMenu.js.map +1 -0
  75. package/dist/react/TableFloatingToolbar.d.ts +7 -0
  76. package/dist/react/TableFloatingToolbar.d.ts.map +1 -0
  77. package/dist/react/TableFloatingToolbar.js +108 -0
  78. package/dist/react/TableFloatingToolbar.js.map +1 -0
  79. package/dist/react/TiptapEditor.d.ts +20 -0
  80. package/dist/react/TiptapEditor.d.ts.map +1 -0
  81. package/dist/react/TiptapEditor.js +398 -0
  82. package/dist/react/TiptapEditor.js.map +1 -0
  83. package/dist/react/Toolbar.d.ts +45 -0
  84. package/dist/react/Toolbar.d.ts.map +1 -0
  85. package/dist/react/Toolbar.js +204 -0
  86. package/dist/react/Toolbar.js.map +1 -0
  87. package/dist/react/toolbarButtons.d.ts +36 -0
  88. package/dist/react/toolbarButtons.d.ts.map +1 -0
  89. package/dist/react/toolbarButtons.js +300 -0
  90. package/dist/react/toolbarButtons.js.map +1 -0
  91. package/dist/register.d.ts +20 -0
  92. package/dist/register.d.ts.map +1 -0
  93. package/dist/register.js +27 -0
  94. package/dist/register.js.map +1 -0
  95. package/dist/render.d.ts +89 -0
  96. package/dist/render.d.ts.map +1 -0
  97. package/dist/render.js +439 -0
  98. package/dist/render.js.map +1 -0
  99. package/package.json +92 -0
  100. package/src/Block.ts +75 -0
  101. package/src/MentionProvider.ts +153 -0
  102. package/src/RichTextField.test.ts +447 -0
  103. package/src/RichTextField.ts +508 -0
  104. package/src/extensions/BlockNodeExtension.ts +134 -0
  105. package/src/extensions/DragHandleExtension.ts +184 -0
  106. package/src/extensions/GridExtension.test.ts +31 -0
  107. package/src/extensions/GridExtension.ts +138 -0
  108. package/src/extensions/MentionExtension.ts +248 -0
  109. package/src/extensions/MergeTagExtension.ts +75 -0
  110. package/src/extensions/SlashCommandExtension.test.ts +147 -0
  111. package/src/extensions/SlashCommandExtension.ts +332 -0
  112. package/src/extensions/TextSizeMarks.ts +73 -0
  113. package/src/index.ts +28 -0
  114. package/src/plugin.test.ts +19 -0
  115. package/src/plugin.ts +26 -0
  116. package/src/react/BlockNodeView.tsx +99 -0
  117. package/src/react/BlockSidePanel.test.ts +412 -0
  118. package/src/react/BlockSidePanel.tsx +451 -0
  119. package/src/react/FloatingToolbar.tsx +304 -0
  120. package/src/react/MentionMenu.tsx +120 -0
  121. package/src/react/Palette.tsx +86 -0
  122. package/src/react/SlashMenu.tsx +129 -0
  123. package/src/react/TableFloatingToolbar.tsx +154 -0
  124. package/src/react/TiptapEditor.tsx +535 -0
  125. package/src/react/Toolbar.tsx +438 -0
  126. package/src/react/toolbarButtons.tsx +579 -0
  127. package/src/register.test.ts +14 -0
  128. package/src/register.ts +27 -0
  129. package/src/render.test.ts +745 -0
  130. package/src/render.ts +480 -0
@@ -0,0 +1,745 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { renderRichTextToHtml, isRichTextValue, type TiptapNode } from './render.js'
5
+
6
+ describe('renderRichTextToHtml — empty / fallback inputs', () => {
7
+ it('returns empty string for null / undefined', () => {
8
+ assert.equal(renderRichTextToHtml(null), '')
9
+ assert.equal(renderRichTextToHtml(undefined), '')
10
+ })
11
+
12
+ it('returns empty string for unparseable JSON-looking strings', () => {
13
+ assert.equal(renderRichTextToHtml('{not json'), '')
14
+ })
15
+
16
+ it('passes raw HTML strings through verbatim', () => {
17
+ const html = '<p>already <strong>HTML</strong></p>'
18
+ assert.equal(renderRichTextToHtml(html), html)
19
+ })
20
+
21
+ it('renders an empty doc to empty output', () => {
22
+ assert.equal(renderRichTextToHtml({ type: 'doc', content: [] }), '')
23
+ })
24
+ })
25
+
26
+ describe('renderRichTextToHtml — nodes', () => {
27
+ it('paragraph with a text leaf', () => {
28
+ const doc: TiptapNode = {
29
+ type: 'doc',
30
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }],
31
+ }
32
+ assert.equal(renderRichTextToHtml(doc), '<p>Hello</p>')
33
+ })
34
+
35
+ it('headings clamp to h1..h6', () => {
36
+ const mk = (level: unknown): TiptapNode => ({
37
+ type: 'doc',
38
+ content: [{ type: 'heading', attrs: { level }, content: [{ type: 'text', text: 'Hi' }] }],
39
+ })
40
+ assert.equal(renderRichTextToHtml(mk(2)), '<h2>Hi</h2>')
41
+ assert.equal(renderRichTextToHtml(mk(0)), '<h1>Hi</h1>') // clamp low
42
+ assert.equal(renderRichTextToHtml(mk(99)), '<h6>Hi</h6>') // clamp high
43
+ assert.equal(renderRichTextToHtml(mk('x')), '<h1>Hi</h1>') // non-numeric → 1
44
+ })
45
+
46
+ it('paragraph + heading carry textAlign as inline style (skipped for left)', () => {
47
+ const right: TiptapNode = {
48
+ type: 'doc',
49
+ content: [{ type: 'paragraph', attrs: { textAlign: 'right' }, content: [{ type: 'text', text: 'r' }] }],
50
+ }
51
+ assert.equal(renderRichTextToHtml(right), '<p style="text-align: right">r</p>')
52
+ const left: TiptapNode = {
53
+ type: 'doc',
54
+ content: [{ type: 'paragraph', attrs: { textAlign: 'left' }, content: [{ type: 'text', text: 'l' }] }],
55
+ }
56
+ assert.equal(renderRichTextToHtml(left), '<p>l</p>')
57
+ })
58
+
59
+ it('blockquote / lists / horizontalRule / hardBreak', () => {
60
+ const doc: TiptapNode = {
61
+ type: 'doc',
62
+ content: [
63
+ { type: 'blockquote', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'q' }] }] },
64
+ { type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'a' }] }] }] },
65
+ { type: 'orderedList', attrs: { start: 3 }, content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'b' }] }] }] },
66
+ { type: 'horizontalRule' },
67
+ { type: 'paragraph', content: [{ type: 'text', text: 'one' }, { type: 'hardBreak' }, { type: 'text', text: 'two' }] },
68
+ ],
69
+ }
70
+ const html = renderRichTextToHtml(doc)
71
+ assert.match(html, /<blockquote><p>q<\/p><\/blockquote>/)
72
+ assert.match(html, /<ul><li><p>a<\/p><\/li><\/ul>/)
73
+ assert.match(html, /<ol start="3"><li><p>b<\/p><\/li><\/ol>/)
74
+ assert.match(html, /<hr>/)
75
+ assert.match(html, /<p>one<br>two<\/p>/)
76
+ })
77
+
78
+ it('codeBlock honors language', () => {
79
+ const doc: TiptapNode = {
80
+ type: 'doc',
81
+ content: [{
82
+ type: 'codeBlock',
83
+ attrs: { language: 'ts' },
84
+ content: [{ type: 'text', text: 'const x = 1;' }],
85
+ }],
86
+ }
87
+ assert.equal(renderRichTextToHtml(doc), '<pre><code class="language-ts">const x = 1;</code></pre>')
88
+ })
89
+ })
90
+
91
+ describe('renderRichTextToHtml — image', () => {
92
+ it('renders src + alt + width/height when present', () => {
93
+ const doc: TiptapNode = {
94
+ type: 'doc',
95
+ content: [{
96
+ type: 'image',
97
+ attrs: { src: 'https://example.com/a.png', alt: 'cat', width: 320, height: 240 },
98
+ }],
99
+ }
100
+ assert.equal(
101
+ renderRichTextToHtml(doc),
102
+ '<img src="https://example.com/a.png" alt="cat" width="320" height="240">',
103
+ )
104
+ })
105
+
106
+ it('emits empty alt when alt missing', () => {
107
+ const doc: TiptapNode = {
108
+ type: 'doc',
109
+ content: [{ type: 'image', attrs: { src: '/u/file.png' } }],
110
+ }
111
+ assert.equal(renderRichTextToHtml(doc), '<img src="/u/file.png" alt="">')
112
+ })
113
+
114
+ it('drops bad width / non-finite dimensions', () => {
115
+ const doc: TiptapNode = {
116
+ type: 'doc',
117
+ content: [{
118
+ type: 'image',
119
+ attrs: { src: '/x.png', width: 'abc', height: -10 },
120
+ }],
121
+ }
122
+ assert.equal(renderRichTextToHtml(doc), '<img src="/x.png" alt="">')
123
+ })
124
+
125
+ it('escapes alt + title attributes', () => {
126
+ const doc: TiptapNode = {
127
+ type: 'doc',
128
+ content: [{
129
+ type: 'image',
130
+ attrs: { src: '/x.png', alt: 'a"b', title: '<script>' },
131
+ }],
132
+ }
133
+ assert.equal(
134
+ renderRichTextToHtml(doc),
135
+ '<img src="/x.png" alt="a&quot;b" title="&lt;script&gt;">',
136
+ )
137
+ })
138
+
139
+ it('drops javascript: src entirely (no broken <img>)', () => {
140
+ const doc: TiptapNode = {
141
+ type: 'doc',
142
+ content: [{ type: 'image', attrs: { src: 'javascript:alert(1)' } }],
143
+ }
144
+ assert.equal(renderRichTextToHtml(doc), '')
145
+ })
146
+ })
147
+
148
+ describe('renderRichTextToHtml — tables', () => {
149
+ it('renders a 2x2 table with a header row + tbody wrapper', () => {
150
+ const doc: TiptapNode = {
151
+ type: 'doc',
152
+ content: [{
153
+ type: 'table',
154
+ content: [
155
+ {
156
+ type: 'tableRow',
157
+ content: [
158
+ { type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }] },
159
+ { type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] }] },
160
+ ],
161
+ },
162
+ {
163
+ type: 'tableRow',
164
+ content: [
165
+ { type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: '1' }] }] },
166
+ { type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: '2' }] }] },
167
+ ],
168
+ },
169
+ ],
170
+ }],
171
+ }
172
+ const html = renderRichTextToHtml(doc)
173
+ assert.equal(
174
+ html,
175
+ '<table><tbody>' +
176
+ '<tr><th><p>A</p></th><th><p>B</p></th></tr>' +
177
+ '<tr><td><p>1</p></td><td><p>2</p></td></tr>' +
178
+ '</tbody></table>',
179
+ )
180
+ })
181
+
182
+ it('emits a <colgroup> when the first row carries colwidths', () => {
183
+ const doc: TiptapNode = {
184
+ type: 'doc',
185
+ content: [{
186
+ type: 'table',
187
+ content: [{
188
+ type: 'tableRow',
189
+ content: [
190
+ { type: 'tableCell', attrs: { colwidth: [120] }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'a' }] }] },
191
+ { type: 'tableCell', attrs: { colwidth: [200] }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'b' }] }] },
192
+ ],
193
+ }],
194
+ }],
195
+ }
196
+ const html = renderRichTextToHtml(doc)
197
+ assert.match(html, /<colgroup><col style="width: 120px"><col style="width: 200px"><\/colgroup>/)
198
+ })
199
+
200
+ it('honors colspan + rowspan on cells', () => {
201
+ const doc: TiptapNode = {
202
+ type: 'doc',
203
+ content: [{
204
+ type: 'table',
205
+ content: [{
206
+ type: 'tableRow',
207
+ content: [
208
+ { type: 'tableCell', attrs: { colspan: 2, rowspan: 3 }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'm' }] }] },
209
+ { type: 'tableCell', attrs: { colspan: 1, rowspan: 1 }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'n' }] }] },
210
+ ],
211
+ }],
212
+ }],
213
+ }
214
+ const html = renderRichTextToHtml(doc)
215
+ assert.match(html, /<td colspan="2" rowspan="3"><p>m<\/p><\/td>/)
216
+ // Default span values (1) get omitted.
217
+ assert.match(html, /<td><p>n<\/p><\/td>/)
218
+ })
219
+
220
+ it('drops bad colspan / rowspan / colwidth values', () => {
221
+ const doc: TiptapNode = {
222
+ type: 'doc',
223
+ content: [{
224
+ type: 'table',
225
+ content: [{
226
+ type: 'tableRow',
227
+ content: [
228
+ { type: 'tableCell', attrs: { colspan: -1, rowspan: 'x', colwidth: ['bad', null] }, content: [] },
229
+ ],
230
+ }],
231
+ }],
232
+ }
233
+ const html = renderRichTextToHtml(doc)
234
+ // colspan / rowspan / colwidth all unparseable → no colgroup, no span
235
+ // attributes, plain `<td></td>`.
236
+ assert.equal(
237
+ html,
238
+ '<table><tbody><tr><td></td></tr></tbody></table>',
239
+ )
240
+ })
241
+
242
+ it('mixes resolved + unresolved widths into one colgroup', () => {
243
+ const doc: TiptapNode = {
244
+ type: 'doc',
245
+ content: [{
246
+ type: 'table',
247
+ content: [{
248
+ type: 'tableRow',
249
+ content: [
250
+ { type: 'tableCell', attrs: { colwidth: [120] }, content: [] },
251
+ { type: 'tableCell', content: [] },
252
+ ],
253
+ }],
254
+ }],
255
+ }
256
+ const html = renderRichTextToHtml(doc)
257
+ assert.match(html, /<colgroup><col style="width: 120px"><col><\/colgroup>/)
258
+ })
259
+ })
260
+
261
+ describe('renderRichTextToHtml — marks', () => {
262
+ it('inline marks wrap from innermost to outermost (marks[0] is innermost)', () => {
263
+ const doc: TiptapNode = {
264
+ type: 'doc',
265
+ content: [{
266
+ type: 'paragraph',
267
+ content: [{
268
+ type: 'text',
269
+ text: 'x',
270
+ marks: [{ type: 'italic' }, { type: 'bold' }],
271
+ }],
272
+ }],
273
+ }
274
+ assert.equal(renderRichTextToHtml(doc), '<p><strong><em>x</em></strong></p>')
275
+ })
276
+
277
+ it('underline / sub / sup / strike / code marks', () => {
278
+ const mk = (mark: string): TiptapNode => ({
279
+ type: 'doc',
280
+ content: [{
281
+ type: 'paragraph',
282
+ content: [{ type: 'text', text: 't', marks: [{ type: mark }] }],
283
+ }],
284
+ })
285
+ assert.equal(renderRichTextToHtml(mk('underline')), '<p><u>t</u></p>')
286
+ assert.equal(renderRichTextToHtml(mk('subscript')), '<p><sub>t</sub></p>')
287
+ assert.equal(renderRichTextToHtml(mk('superscript')), '<p><sup>t</sup></p>')
288
+ assert.equal(renderRichTextToHtml(mk('strike')), '<p><s>t</s></p>')
289
+ assert.equal(renderRichTextToHtml(mk('code')), '<p><code>t</code></p>')
290
+ })
291
+
292
+ it('lead / small size-variant marks', () => {
293
+ const mk = (mark: string): TiptapNode => ({
294
+ type: 'doc',
295
+ content: [{
296
+ type: 'paragraph',
297
+ content: [{ type: 'text', text: 't', marks: [{ type: mark }] }],
298
+ }],
299
+ })
300
+ assert.equal(renderRichTextToHtml(mk('lead')), '<p><span class="lead">t</span></p>')
301
+ assert.equal(renderRichTextToHtml(mk('small')), '<p><small>t</small></p>')
302
+ })
303
+
304
+ it('link marks emit href + opens-in-new-tab gets rel=noopener', () => {
305
+ const doc: TiptapNode = {
306
+ type: 'doc',
307
+ content: [{
308
+ type: 'paragraph',
309
+ content: [{
310
+ type: 'text',
311
+ text: 'click',
312
+ marks: [{ type: 'link', attrs: { href: 'https://example.com', target: '_blank' } }],
313
+ }],
314
+ }],
315
+ }
316
+ assert.equal(
317
+ renderRichTextToHtml(doc),
318
+ '<p><a href="https://example.com" target="_blank" rel="noopener noreferrer">click</a></p>',
319
+ )
320
+ })
321
+
322
+ it('javascript: URLs in link marks fall back to "#"', () => {
323
+ const doc: TiptapNode = {
324
+ type: 'doc',
325
+ content: [{
326
+ type: 'paragraph',
327
+ content: [{
328
+ type: 'text',
329
+ text: 'pwn',
330
+ marks: [{ type: 'link', attrs: { href: 'javascript:alert(1)' } }],
331
+ }],
332
+ }],
333
+ }
334
+ assert.equal(renderRichTextToHtml(doc), '<p><a href="#">pwn</a></p>')
335
+ })
336
+
337
+ it('textStyle.color / highlight.color sanitize against unsafe values', () => {
338
+ const ok: TiptapNode = {
339
+ type: 'doc',
340
+ content: [{
341
+ type: 'paragraph',
342
+ content: [{
343
+ type: 'text', text: 'c',
344
+ marks: [
345
+ { type: 'textStyle', attrs: { color: '#ff0000' } },
346
+ { type: 'highlight', attrs: { color: 'yellow' } },
347
+ ],
348
+ }],
349
+ }],
350
+ }
351
+ assert.equal(
352
+ renderRichTextToHtml(ok),
353
+ '<p><mark style="background-color: yellow"><span style="color: #ff0000">c</span></mark></p>',
354
+ )
355
+
356
+ const bad: TiptapNode = {
357
+ type: 'doc',
358
+ content: [{
359
+ type: 'paragraph',
360
+ content: [{
361
+ type: 'text', text: 'c',
362
+ marks: [{ type: 'textStyle', attrs: { color: 'expression(alert(1))' } }],
363
+ }],
364
+ }],
365
+ }
366
+ // Unsafe color drops the wrapping span entirely.
367
+ assert.equal(renderRichTextToHtml(bad), '<p>c</p>')
368
+ })
369
+ })
370
+
371
+ describe('renderRichTextToHtml — escaping', () => {
372
+ it('text content escapes HTML metacharacters', () => {
373
+ const doc: TiptapNode = {
374
+ type: 'doc',
375
+ content: [{
376
+ type: 'paragraph',
377
+ content: [{ type: 'text', text: '<script>alert("x")</script>' }],
378
+ }],
379
+ }
380
+ assert.equal(
381
+ renderRichTextToHtml(doc),
382
+ '<p>&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;</p>',
383
+ )
384
+ })
385
+
386
+ it('codeBlock language attribute escapes injection attempts', () => {
387
+ const doc: TiptapNode = {
388
+ type: 'doc',
389
+ content: [{
390
+ type: 'codeBlock',
391
+ attrs: { language: 'a"><script>x' },
392
+ content: [{ type: 'text', text: '' }],
393
+ }],
394
+ }
395
+ assert.match(renderRichTextToHtml(doc), /class="language-a&quot;&gt;&lt;script&gt;x"/)
396
+ })
397
+ })
398
+
399
+ describe('renderRichTextToHtml — details (collapsible blocks)', () => {
400
+ it('renders a closed details block with summary + content', () => {
401
+ const doc: TiptapNode = {
402
+ type: 'doc',
403
+ content: [{
404
+ type: 'details',
405
+ content: [
406
+ { type: 'detailsSummary', content: [{ type: 'text', text: 'Click me' }] },
407
+ { type: 'detailsContent', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hidden' }] }] },
408
+ ],
409
+ }],
410
+ }
411
+ assert.equal(
412
+ renderRichTextToHtml(doc),
413
+ '<details><summary>Click me</summary><p>Hidden</p></details>',
414
+ )
415
+ })
416
+
417
+ it('emits the `open` attribute when attrs.open is true', () => {
418
+ const doc: TiptapNode = {
419
+ type: 'doc',
420
+ content: [{
421
+ type: 'details',
422
+ attrs: { open: true },
423
+ content: [
424
+ { type: 'detailsSummary', content: [{ type: 'text', text: 'S' }] },
425
+ { type: 'detailsContent', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] }] },
426
+ ],
427
+ }],
428
+ }
429
+ assert.equal(
430
+ renderRichTextToHtml(doc),
431
+ '<details open><summary>S</summary><p>B</p></details>',
432
+ )
433
+ })
434
+
435
+ it('treats anything other than `true` as closed (including the string "true")', () => {
436
+ const mk = (open: unknown): TiptapNode => ({
437
+ type: 'doc',
438
+ content: [{
439
+ type: 'details',
440
+ attrs: { open },
441
+ content: [
442
+ { type: 'detailsSummary', content: [{ type: 'text', text: 's' }] },
443
+ { type: 'detailsContent', content: [{ type: 'paragraph' }] },
444
+ ],
445
+ }],
446
+ })
447
+ // Tiptap stores `open` as a real boolean — anything else is treated as
448
+ // closed so a corrupted attr doesn't accidentally pop everything open.
449
+ assert.match(renderRichTextToHtml(mk('true')), /^<details>/)
450
+ assert.match(renderRichTextToHtml(mk(1)), /^<details>/)
451
+ assert.match(renderRichTextToHtml(mk(null)), /^<details>/)
452
+ })
453
+
454
+ it('escapes summary text content', () => {
455
+ const doc: TiptapNode = {
456
+ type: 'doc',
457
+ content: [{
458
+ type: 'details',
459
+ content: [
460
+ { type: 'detailsSummary', content: [{ type: 'text', text: '<script>' }] },
461
+ { type: 'detailsContent', content: [{ type: 'paragraph' }] },
462
+ ],
463
+ }],
464
+ }
465
+ assert.match(renderRichTextToHtml(doc), /<summary>&lt;script&gt;<\/summary>/)
466
+ })
467
+ })
468
+
469
+ describe('renderRichTextToHtml — grid (multi-column blocks)', () => {
470
+ it('renders a 2-column grid with paragraph children in each column', () => {
471
+ const doc: TiptapNode = {
472
+ type: 'doc',
473
+ content: [{
474
+ type: 'grid',
475
+ attrs: { columns: 2 },
476
+ content: [
477
+ { type: 'gridColumn', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Left' }] }] },
478
+ { type: 'gridColumn', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Right' }] }] },
479
+ ],
480
+ }],
481
+ }
482
+ assert.equal(
483
+ renderRichTextToHtml(doc),
484
+ '<div class="pilotiq-grid pilotiq-grid-cols-2"><div><p>Left</p></div><div><p>Right</p></div></div>',
485
+ )
486
+ })
487
+
488
+ it('renders a 3-column grid with the matching class', () => {
489
+ const doc: TiptapNode = {
490
+ type: 'doc',
491
+ content: [{
492
+ type: 'grid',
493
+ attrs: { columns: 3 },
494
+ content: [
495
+ { type: 'gridColumn', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }] },
496
+ { type: 'gridColumn', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] }] },
497
+ { type: 'gridColumn', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'C' }] }] },
498
+ ],
499
+ }],
500
+ }
501
+ assert.match(renderRichTextToHtml(doc), /^<div class="pilotiq-grid pilotiq-grid-cols-3">/)
502
+ })
503
+
504
+ it('clamps invalid column counts to 2 so a tampered attr never paints `cols-99`', () => {
505
+ const mk = (columns: unknown): TiptapNode => ({
506
+ type: 'doc',
507
+ content: [{
508
+ type: 'grid',
509
+ attrs: { columns },
510
+ content: [
511
+ { type: 'gridColumn', content: [{ type: 'paragraph' }] },
512
+ { type: 'gridColumn', content: [{ type: 'paragraph' }] },
513
+ ],
514
+ }],
515
+ })
516
+ assert.match(renderRichTextToHtml(mk(99)), /pilotiq-grid-cols-2/)
517
+ assert.match(renderRichTextToHtml(mk(1)), /pilotiq-grid-cols-2/)
518
+ assert.match(renderRichTextToHtml(mk(NaN)), /pilotiq-grid-cols-2/)
519
+ assert.match(renderRichTextToHtml(mk(undefined)), /pilotiq-grid-cols-2/)
520
+ assert.match(renderRichTextToHtml(mk('abc')), /pilotiq-grid-cols-2/)
521
+ })
522
+
523
+ it('coerces numeric strings — Tiptap JSON sometimes round-trips attrs as strings', () => {
524
+ const mk = (columns: unknown): TiptapNode => ({
525
+ type: 'doc',
526
+ content: [{
527
+ type: 'grid',
528
+ attrs: { columns },
529
+ content: [
530
+ { type: 'gridColumn', content: [{ type: 'paragraph' }] },
531
+ { type: 'gridColumn', content: [{ type: 'paragraph' }] },
532
+ ],
533
+ }],
534
+ })
535
+ assert.match(renderRichTextToHtml(mk('3')), /pilotiq-grid-cols-3/)
536
+ })
537
+ })
538
+
539
+ describe('renderRichTextToHtml — merge tags', () => {
540
+ it('substitutes a tag from opts.mergeTags (HTML-escaped)', () => {
541
+ const doc: TiptapNode = {
542
+ type: 'doc',
543
+ content: [{
544
+ type: 'paragraph',
545
+ content: [
546
+ { type: 'text', text: 'Hi ' },
547
+ { type: 'mergeTag', attrs: { id: 'name' } },
548
+ { type: 'text', text: '!' },
549
+ ],
550
+ }],
551
+ }
552
+ assert.equal(
553
+ renderRichTextToHtml(doc, { mergeTags: { name: 'Sleman <Owner>' } }),
554
+ '<p>Hi Sleman &lt;Owner&gt;!</p>',
555
+ )
556
+ })
557
+
558
+ it('falls back to a styled <span> when no map is supplied', () => {
559
+ const doc: TiptapNode = {
560
+ type: 'doc',
561
+ content: [{
562
+ type: 'paragraph',
563
+ content: [{ type: 'mergeTag', attrs: { id: 'firstName' } }],
564
+ }],
565
+ }
566
+ assert.equal(
567
+ renderRichTextToHtml(doc),
568
+ '<p><span class="merge-tag" data-id="firstName">{{ firstName }}</span></p>',
569
+ )
570
+ })
571
+
572
+ it('falls back to the styled <span> for ids missing from the map', () => {
573
+ const doc: TiptapNode = {
574
+ type: 'doc',
575
+ content: [{
576
+ type: 'paragraph',
577
+ content: [{ type: 'mergeTag', attrs: { id: 'company' } }],
578
+ }],
579
+ }
580
+ const html = renderRichTextToHtml(doc, { mergeTags: { name: 'X' } })
581
+ assert.match(html, /<span class="merge-tag" data-id="company">\{\{ company \}\}<\/span>/)
582
+ })
583
+
584
+ it('explicit empty-string substitution still wins over the fallback', () => {
585
+ const doc: TiptapNode = {
586
+ type: 'doc',
587
+ content: [{ type: 'paragraph', content: [{ type: 'mergeTag', attrs: { id: 'opt' } }] }],
588
+ }
589
+ assert.equal(
590
+ renderRichTextToHtml(doc, { mergeTags: { opt: '' } }),
591
+ '<p></p>',
592
+ )
593
+ })
594
+
595
+ it('drops the chip when id is missing or blank', () => {
596
+ const doc: TiptapNode = {
597
+ type: 'doc',
598
+ content: [{
599
+ type: 'paragraph',
600
+ content: [
601
+ { type: 'text', text: 'a' },
602
+ { type: 'mergeTag', attrs: { id: '' } },
603
+ { type: 'text', text: 'b' },
604
+ ],
605
+ }],
606
+ }
607
+ assert.equal(renderRichTextToHtml(doc), '<p>ab</p>')
608
+ })
609
+ })
610
+
611
+ describe('renderRichTextToHtml — mentions', () => {
612
+ it('renders the cached label inside a styled <span>', () => {
613
+ const doc: TiptapNode = {
614
+ type: 'doc',
615
+ content: [{
616
+ type: 'paragraph',
617
+ content: [
618
+ { type: 'text', text: 'cc ' },
619
+ { type: 'mention', attrs: { id: 'sleman', label: 'Sleman', trigger: '@' } },
620
+ ],
621
+ }],
622
+ }
623
+ assert.equal(
624
+ renderRichTextToHtml(doc),
625
+ '<p>cc <span class="mention" data-trigger="@" data-id="sleman">@Sleman</span></p>',
626
+ )
627
+ })
628
+
629
+ it('falls back to id when label is blank', () => {
630
+ const doc: TiptapNode = {
631
+ type: 'doc',
632
+ content: [{
633
+ type: 'paragraph',
634
+ content: [{ type: 'mention', attrs: { id: 'admin', label: '', trigger: '@' } }],
635
+ }],
636
+ }
637
+ assert.match(
638
+ renderRichTextToHtml(doc),
639
+ /<span class="mention" data-trigger="@" data-id="admin">@admin<\/span>/,
640
+ )
641
+ })
642
+
643
+ it('opts.resolveMention overrides the cached label', () => {
644
+ const doc: TiptapNode = {
645
+ type: 'doc',
646
+ content: [{
647
+ type: 'paragraph',
648
+ content: [{ type: 'mention', attrs: { id: 'sleman', label: 'Old name', trigger: '@' } }],
649
+ }],
650
+ }
651
+ assert.match(
652
+ renderRichTextToHtml(doc, {
653
+ resolveMention: (trigger, id) => trigger === '@' && id === 'sleman' ? 'New Name' : undefined,
654
+ }),
655
+ /<span class="mention" data-trigger="@" data-id="sleman">@New Name<\/span>/,
656
+ )
657
+ })
658
+
659
+ it('escapes labels that contain HTML metacharacters', () => {
660
+ const doc: TiptapNode = {
661
+ type: 'doc',
662
+ content: [{
663
+ type: 'paragraph',
664
+ content: [{ type: 'mention', attrs: { id: 'x', label: '<script>', trigger: '@' } }],
665
+ }],
666
+ }
667
+ assert.match(renderRichTextToHtml(doc), /@&lt;script&gt;/)
668
+ })
669
+
670
+ it('drops the chip when id or trigger is missing', () => {
671
+ const noId: TiptapNode = {
672
+ type: 'doc',
673
+ content: [{
674
+ type: 'paragraph',
675
+ content: [{ type: 'mention', attrs: { label: 'L', trigger: '@' } }],
676
+ }],
677
+ }
678
+ assert.equal(renderRichTextToHtml(noId), '<p></p>')
679
+
680
+ const noTrig: TiptapNode = {
681
+ type: 'doc',
682
+ content: [{
683
+ type: 'paragraph',
684
+ content: [{ type: 'mention', attrs: { id: 'i', label: 'L' } }],
685
+ }],
686
+ }
687
+ assert.equal(renderRichTextToHtml(noTrig), '<p></p>')
688
+ })
689
+ })
690
+
691
+ describe('renderRichTextToHtml — custom blocks', () => {
692
+ it('unknown nodes emit a data-type wrapper carrying attrs', () => {
693
+ const doc: TiptapNode = {
694
+ type: 'doc',
695
+ content: [{
696
+ type: 'callout',
697
+ attrs: { tone: 'warning' },
698
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }],
699
+ }],
700
+ }
701
+ const html = renderRichTextToHtml(doc)
702
+ assert.match(html, /<div data-type="callout" data-attrs="\{&quot;tone&quot;:&quot;warning&quot;\}"><p>hi<\/p><\/div>/)
703
+ })
704
+
705
+ it('renderBlock option overrides the default custom-block fallback', () => {
706
+ const doc: TiptapNode = {
707
+ type: 'doc',
708
+ content: [{ type: 'callout', attrs: { tone: 'info' } }],
709
+ }
710
+ const html = renderRichTextToHtml(doc, {
711
+ renderBlock: (n) => `<aside class="${n.type}">${(n.attrs?.['tone'] ?? '')}</aside>`,
712
+ })
713
+ assert.equal(html, '<aside class="callout">info</aside>')
714
+ })
715
+ })
716
+
717
+ describe('renderRichTextToHtml — string input', () => {
718
+ it('parses a JSON-encoded doc string', () => {
719
+ const json = JSON.stringify({
720
+ type: 'doc',
721
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'json' }] }],
722
+ })
723
+ assert.equal(renderRichTextToHtml(json), '<p>json</p>')
724
+ })
725
+ })
726
+
727
+ describe('isRichTextValue', () => {
728
+ it('matches Tiptap doc objects', () => {
729
+ assert.equal(isRichTextValue({ type: 'doc', content: [] }), true)
730
+ })
731
+
732
+ it('matches JSON-encoded Tiptap doc strings', () => {
733
+ assert.equal(isRichTextValue('{"type":"doc","content":[]}'), true)
734
+ })
735
+
736
+ it('rejects plain strings, raw HTML, arbitrary objects', () => {
737
+ assert.equal(isRichTextValue(null), false)
738
+ assert.equal(isRichTextValue(''), false)
739
+ assert.equal(isRichTextValue('<p>html</p>'), false)
740
+ assert.equal(isRichTextValue({ type: 'paragraph' }), false)
741
+ assert.equal(isRichTextValue({ type: 'doc' }), false) // no content[]
742
+ assert.equal(isRichTextValue({ foo: 'bar' }), false)
743
+ assert.equal(isRichTextValue('{not json'), false)
744
+ })
745
+ })