@pilotiq/tiptap 3.10.5 → 3.10.6

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 (55) hide show
  1. package/CHANGELOG.md +745 -0
  2. package/boost/guidelines.md +268 -0
  3. package/boost/skills/pilotiq-tiptap-blocks/SKILL.md +48 -0
  4. package/boost/skills/pilotiq-tiptap-blocks/rules/custom-blocks.md +90 -0
  5. package/boost/skills/pilotiq-tiptap-blocks/rules/slash-menu-and-mentions.md +101 -0
  6. package/boost/skills/pilotiq-tiptap-blocks/rules/toolbar-and-extensibility.md +161 -0
  7. package/package.json +4 -3
  8. package/src/Block.ts +0 -75
  9. package/src/MentionProvider.ts +0 -153
  10. package/src/PlainTextEditor.dom.test.ts +0 -111
  11. package/src/PlainTextEditor.test.ts +0 -158
  12. package/src/PlainTextEditor.ts +0 -229
  13. package/src/RichTextField.test.ts +0 -447
  14. package/src/RichTextField.ts +0 -508
  15. package/src/extensions/AiInlineDiffExtension.ts +0 -286
  16. package/src/extensions/AiSuggestionExtension.test.ts +0 -141
  17. package/src/extensions/AiSuggestionExtension.ts +0 -522
  18. package/src/extensions/BlockNodeExtension.ts +0 -134
  19. package/src/extensions/DragHandleExtension.ts +0 -184
  20. package/src/extensions/GridExtension.test.ts +0 -31
  21. package/src/extensions/GridExtension.ts +0 -138
  22. package/src/extensions/MentionExtension.ts +0 -248
  23. package/src/extensions/MergeTagExtension.ts +0 -75
  24. package/src/extensions/SlashCommandExtension.test.ts +0 -147
  25. package/src/extensions/SlashCommandExtension.ts +0 -332
  26. package/src/extensions/TextSizeMarks.ts +0 -73
  27. package/src/index.ts +0 -62
  28. package/src/markdownExtension.ts +0 -19
  29. package/src/markdownStorage.ts +0 -49
  30. package/src/plugin.test.ts +0 -19
  31. package/src/plugin.ts +0 -26
  32. package/src/react/AiSuggestionBanner.tsx +0 -185
  33. package/src/react/BlockNodeView.tsx +0 -99
  34. package/src/react/BlockSidePanel.dom.test.tsx +0 -38
  35. package/src/react/BlockSidePanel.test.ts +0 -412
  36. package/src/react/BlockSidePanel.tsx +0 -451
  37. package/src/react/CollabTextRenderer.tsx +0 -228
  38. package/src/react/FloatingToolbar.tsx +0 -304
  39. package/src/react/MarkdownEditor.tsx +0 -603
  40. package/src/react/MentionMenu.tsx +0 -120
  41. package/src/react/Palette.tsx +0 -86
  42. package/src/react/SlashMenu.tsx +0 -129
  43. package/src/react/TableFloatingToolbar.tsx +0 -154
  44. package/src/react/TiptapEditor.dom.test.tsx +0 -112
  45. package/src/react/TiptapEditor.tsx +0 -777
  46. package/src/react/Toolbar.tsx +0 -438
  47. package/src/react/toolbarButtons.tsx +0 -579
  48. package/src/react/useAiInlineDiff.ts +0 -342
  49. package/src/react/useAiSuggestionBridge.ts +0 -223
  50. package/src/register.test.ts +0 -14
  51. package/src/register.ts +0 -42
  52. package/src/render.test.ts +0 -745
  53. package/src/render.ts +0 -480
  54. package/src/surgicalOps.ts +0 -205
  55. package/src/test/setup.ts +0 -64
@@ -1,745 +0,0 @@
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
- })