@pyreon/document 0.0.1

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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/lib/analysis/index.js.html +5406 -0
  3. package/lib/chunk-ErZ26oRB.js +48 -0
  4. package/lib/confluence-Va8e7RxQ.js +192 -0
  5. package/lib/confluence-Va8e7RxQ.js.map +1 -0
  6. package/lib/csv-2c38ub-Y.js +32 -0
  7. package/lib/csv-2c38ub-Y.js.map +1 -0
  8. package/lib/discord-DAoUZqvE.js +134 -0
  9. package/lib/discord-DAoUZqvE.js.map +1 -0
  10. package/lib/dist-BsqdI2nY.js +20179 -0
  11. package/lib/dist-BsqdI2nY.js.map +1 -0
  12. package/lib/docx-CorFwEH9.js +450 -0
  13. package/lib/docx-CorFwEH9.js.map +1 -0
  14. package/lib/email-Bn_Brjdp.js +131 -0
  15. package/lib/email-Bn_Brjdp.js.map +1 -0
  16. package/lib/exceljs-BoIDUUaw.js +34377 -0
  17. package/lib/exceljs-BoIDUUaw.js.map +1 -0
  18. package/lib/google-chat-B6I017I1.js +125 -0
  19. package/lib/google-chat-B6I017I1.js.map +1 -0
  20. package/lib/html-De_iS_f0.js +151 -0
  21. package/lib/html-De_iS_f0.js.map +1 -0
  22. package/lib/index.js +619 -0
  23. package/lib/index.js.map +1 -0
  24. package/lib/markdown-BYC_3C9i.js +75 -0
  25. package/lib/markdown-BYC_3C9i.js.map +1 -0
  26. package/lib/notion-DHaQHO6P.js +187 -0
  27. package/lib/notion-DHaQHO6P.js.map +1 -0
  28. package/lib/pdf-CDPc5Itc.js +419 -0
  29. package/lib/pdf-CDPc5Itc.js.map +1 -0
  30. package/lib/pdfmake-DnmLxK4Q.js +55511 -0
  31. package/lib/pdfmake-DnmLxK4Q.js.map +1 -0
  32. package/lib/pptx-DKQU6bjq.js +252 -0
  33. package/lib/pptx-DKQU6bjq.js.map +1 -0
  34. package/lib/pptxgen.es-COcgXsyx.js +5697 -0
  35. package/lib/pptxgen.es-COcgXsyx.js.map +1 -0
  36. package/lib/slack-CJRJgkag.js +139 -0
  37. package/lib/slack-CJRJgkag.js.map +1 -0
  38. package/lib/svg-BM8biZmL.js +187 -0
  39. package/lib/svg-BM8biZmL.js.map +1 -0
  40. package/lib/teams-S99tonRG.js +176 -0
  41. package/lib/teams-S99tonRG.js.map +1 -0
  42. package/lib/telegram-CbEO_2PN.js +77 -0
  43. package/lib/telegram-CbEO_2PN.js.map +1 -0
  44. package/lib/text-B5U8ucRr.js +75 -0
  45. package/lib/text-B5U8ucRr.js.map +1 -0
  46. package/lib/types/index.d.ts +528 -0
  47. package/lib/types/index.d.ts.map +1 -0
  48. package/lib/vfs_fonts-Df1kkZ4Y.js +19 -0
  49. package/lib/vfs_fonts-Df1kkZ4Y.js.map +1 -0
  50. package/lib/whatsapp-DJ2D1jGG.js +64 -0
  51. package/lib/whatsapp-DJ2D1jGG.js.map +1 -0
  52. package/lib/xlsx-D47x-gZ5.js +199 -0
  53. package/lib/xlsx-D47x-gZ5.js.map +1 -0
  54. package/package.json +62 -0
  55. package/src/builder.ts +266 -0
  56. package/src/download.ts +76 -0
  57. package/src/env.d.ts +17 -0
  58. package/src/index.ts +98 -0
  59. package/src/nodes.ts +315 -0
  60. package/src/render.ts +222 -0
  61. package/src/renderers/confluence.ts +231 -0
  62. package/src/renderers/csv.ts +67 -0
  63. package/src/renderers/discord.ts +192 -0
  64. package/src/renderers/docx.ts +612 -0
  65. package/src/renderers/email.ts +230 -0
  66. package/src/renderers/google-chat.ts +211 -0
  67. package/src/renderers/html.ts +225 -0
  68. package/src/renderers/markdown.ts +144 -0
  69. package/src/renderers/notion.ts +264 -0
  70. package/src/renderers/pdf.ts +427 -0
  71. package/src/renderers/pptx.ts +353 -0
  72. package/src/renderers/slack.ts +192 -0
  73. package/src/renderers/svg.ts +254 -0
  74. package/src/renderers/teams.ts +234 -0
  75. package/src/renderers/telegram.ts +137 -0
  76. package/src/renderers/text.ts +154 -0
  77. package/src/renderers/whatsapp.ts +121 -0
  78. package/src/renderers/xlsx.ts +342 -0
  79. package/src/tests/document.test.ts +2920 -0
  80. package/src/types.ts +291 -0
@@ -0,0 +1,2920 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import {
3
+ Button,
4
+ Code,
5
+ Column,
6
+ createDocument,
7
+ Divider,
8
+ Document,
9
+ Heading,
10
+ Image,
11
+ isDocNode,
12
+ Link,
13
+ List,
14
+ ListItem,
15
+ Page,
16
+ PageBreak,
17
+ Quote,
18
+ Row,
19
+ Section,
20
+ Spacer,
21
+ Table,
22
+ Text,
23
+ _resetRenderers,
24
+ registerRenderer,
25
+ render,
26
+ unregisterRenderer,
27
+ } from '../index'
28
+
29
+ afterEach(() => {
30
+ _resetRenderers()
31
+ })
32
+
33
+ // ─── Node Construction ──────────────────────────────────────────────────────
34
+
35
+ describe('node construction', () => {
36
+ it('Document creates a document node', () => {
37
+ const doc = Document({ title: 'Test', children: 'hello' })
38
+ expect(doc.type).toBe('document')
39
+ expect(doc.props.title).toBe('Test')
40
+ expect(doc.children).toEqual(['hello'])
41
+ })
42
+
43
+ it('Page creates a page node', () => {
44
+ const page = Page({ size: 'A4', margin: 40, children: 'content' })
45
+ expect(page.type).toBe('page')
46
+ expect(page.props.size).toBe('A4')
47
+ expect(page.props.margin).toBe(40)
48
+ })
49
+
50
+ it('Section with direction', () => {
51
+ const section = Section({ direction: 'row', gap: 20, children: 'a' })
52
+ expect(section.type).toBe('section')
53
+ expect(section.props.direction).toBe('row')
54
+ expect(section.props.gap).toBe(20)
55
+ })
56
+
57
+ it('Row and Column', () => {
58
+ const row = Row({
59
+ gap: 10,
60
+ children: [Column({ width: '50%', children: 'left' })],
61
+ })
62
+ expect(row.type).toBe('row')
63
+ expect(row.children).toHaveLength(1)
64
+ const col = row.children[0]!
65
+ expect(typeof col).not.toBe('string')
66
+ if (typeof col !== 'string') {
67
+ expect(col.type).toBe('column')
68
+ expect(col.props.width).toBe('50%')
69
+ }
70
+ })
71
+
72
+ it('Heading defaults to level 1', () => {
73
+ const h = Heading({ children: 'Title' })
74
+ expect(h.type).toBe('heading')
75
+ expect(h.props.level).toBe(1)
76
+ })
77
+
78
+ it('Heading with custom level', () => {
79
+ const h = Heading({ level: 3, children: 'Subtitle' })
80
+ expect(h.props.level).toBe(3)
81
+ })
82
+
83
+ it('Text with formatting', () => {
84
+ const t = Text({
85
+ bold: true,
86
+ italic: true,
87
+ size: 14,
88
+ color: '#333',
89
+ children: 'hello',
90
+ })
91
+ expect(t.type).toBe('text')
92
+ expect(t.props.bold).toBe(true)
93
+ expect(t.props.italic).toBe(true)
94
+ expect(t.props.size).toBe(14)
95
+ })
96
+
97
+ it('Link', () => {
98
+ const l = Link({ href: 'https://example.com', children: 'click' })
99
+ expect(l.type).toBe('link')
100
+ expect(l.props.href).toBe('https://example.com')
101
+ })
102
+
103
+ it('Image with all props', () => {
104
+ const img = Image({
105
+ src: '/logo.png',
106
+ width: 100,
107
+ height: 50,
108
+ alt: 'Logo',
109
+ caption: 'Company logo',
110
+ })
111
+ expect(img.type).toBe('image')
112
+ expect(img.props.src).toBe('/logo.png')
113
+ expect(img.props.width).toBe(100)
114
+ expect(img.props.caption).toBe('Company logo')
115
+ expect(img.children).toEqual([])
116
+ })
117
+
118
+ it('Table with columns and rows', () => {
119
+ const t = Table({
120
+ columns: ['Name', { header: 'Price', align: 'right' }],
121
+ rows: [['Widget', '$10']],
122
+ striped: true,
123
+ })
124
+ expect(t.type).toBe('table')
125
+ expect(t.props.columns).toHaveLength(2)
126
+ expect(t.props.rows).toHaveLength(1)
127
+ expect(t.props.striped).toBe(true)
128
+ })
129
+
130
+ it('List with items', () => {
131
+ const l = List({
132
+ ordered: true,
133
+ children: [ListItem({ children: 'one' }), ListItem({ children: 'two' })],
134
+ })
135
+ expect(l.type).toBe('list')
136
+ expect(l.props.ordered).toBe(true)
137
+ expect(l.children).toHaveLength(2)
138
+ })
139
+
140
+ it('Code', () => {
141
+ const c = Code({ language: 'typescript', children: 'const x = 1' })
142
+ expect(c.type).toBe('code')
143
+ expect(c.props.language).toBe('typescript')
144
+ })
145
+
146
+ it('Divider', () => {
147
+ const d = Divider({ color: '#ccc', thickness: 2 })
148
+ expect(d.type).toBe('divider')
149
+ expect(d.props.color).toBe('#ccc')
150
+ })
151
+
152
+ it('Divider with defaults', () => {
153
+ const d = Divider()
154
+ expect(d.type).toBe('divider')
155
+ })
156
+
157
+ it('Spacer', () => {
158
+ const s = Spacer({ height: 30 })
159
+ expect(s.type).toBe('spacer')
160
+ expect(s.props.height).toBe(30)
161
+ })
162
+
163
+ it('Button', () => {
164
+ const b = Button({ href: '/pay', background: '#4f46e5', children: 'Pay' })
165
+ expect(b.type).toBe('button')
166
+ expect(b.props.href).toBe('/pay')
167
+ expect(b.props.background).toBe('#4f46e5')
168
+ })
169
+
170
+ it('Quote', () => {
171
+ const q = Quote({ borderColor: '#blue', children: 'wise words' })
172
+ expect(q.type).toBe('quote')
173
+ expect(q.props.borderColor).toBe('#blue')
174
+ })
175
+
176
+ it('isDocNode returns true for nodes', () => {
177
+ expect(isDocNode(Heading({ children: 'hi' }))).toBe(true)
178
+ })
179
+
180
+ it('isDocNode returns false for non-nodes', () => {
181
+ expect(isDocNode('string')).toBe(false)
182
+ expect(isDocNode(null)).toBe(false)
183
+ expect(isDocNode(42)).toBe(false)
184
+ expect(isDocNode({})).toBe(false)
185
+ })
186
+
187
+ it('normalizes nested children', () => {
188
+ const doc = Document({
189
+ children: [
190
+ Heading({ children: 'A' }),
191
+ [Text({ children: 'B' }), Text({ children: 'C' })],
192
+ ],
193
+ })
194
+ expect(doc.children).toHaveLength(3)
195
+ })
196
+
197
+ it('handles null/undefined/false children', () => {
198
+ const doc = Document({ children: [null, undefined, false, 'text'] })
199
+ expect(doc.children).toEqual(['text'])
200
+ })
201
+
202
+ it('converts numbers to strings in children', () => {
203
+ const t = Text({ children: 42 as unknown as string })
204
+ expect(t.children).toEqual(['42'])
205
+ })
206
+ })
207
+
208
+ // ─── HTML Renderer ──────────────────────────────────────────────────────────
209
+
210
+ describe('HTML renderer', () => {
211
+ it('renders a simple document', async () => {
212
+ const doc = Document({
213
+ title: 'Test',
214
+ children: Page({ children: Heading({ children: 'Hello' }) }),
215
+ })
216
+ const html = (await render(doc, 'html')) as string
217
+ expect(html).toContain('<!DOCTYPE html>')
218
+ expect(html).toContain('<title>Test</title>')
219
+ expect(html).toContain('<h1')
220
+ expect(html).toContain('Hello')
221
+ })
222
+
223
+ it('renders text with formatting', async () => {
224
+ const doc = Document({
225
+ children: Text({
226
+ bold: true,
227
+ color: '#f00',
228
+ size: 20,
229
+ align: 'center',
230
+ children: 'Bold Red',
231
+ }),
232
+ })
233
+ const html = (await render(doc, 'html')) as string
234
+ expect(html).toContain('font-weight:bold')
235
+ expect(html).toContain('color:#f00')
236
+ expect(html).toContain('font-size:20px')
237
+ expect(html).toContain('text-align:center')
238
+ })
239
+
240
+ it('renders a table', async () => {
241
+ const doc = Document({
242
+ children: Table({
243
+ columns: ['Name', { header: 'Price', align: 'right' }],
244
+ rows: [
245
+ ['Widget', '$10'],
246
+ ['Gadget', '$20'],
247
+ ],
248
+ striped: true,
249
+ headerStyle: { background: '#000', color: '#fff' },
250
+ }),
251
+ })
252
+ const html = (await render(doc, 'html')) as string
253
+ expect(html).toContain('<table')
254
+ expect(html).toContain('Widget')
255
+ expect(html).toContain('$10')
256
+ expect(html).toContain('background:#000')
257
+ expect(html).toContain('color:#fff')
258
+ })
259
+
260
+ it('renders an image with caption', async () => {
261
+ const doc = Document({
262
+ children: Image({
263
+ src: '/img.png',
264
+ width: 200,
265
+ alt: 'Photo',
266
+ caption: 'A photo',
267
+ }),
268
+ })
269
+ const html = (await render(doc, 'html')) as string
270
+ expect(html).toContain('<img')
271
+ expect(html).toContain('src="/img.png"')
272
+ expect(html).toContain('<figcaption>')
273
+ expect(html).toContain('A photo')
274
+ })
275
+
276
+ it('renders a link', async () => {
277
+ const doc = Document({
278
+ children: Link({ href: 'https://example.com', children: 'Click me' }),
279
+ })
280
+ const html = (await render(doc, 'html')) as string
281
+ expect(html).toContain('href="https://example.com"')
282
+ expect(html).toContain('Click me')
283
+ })
284
+
285
+ it('renders a list', async () => {
286
+ const doc = Document({
287
+ children: List({
288
+ ordered: true,
289
+ children: [
290
+ ListItem({ children: 'one' }),
291
+ ListItem({ children: 'two' }),
292
+ ],
293
+ }),
294
+ })
295
+ const html = (await render(doc, 'html')) as string
296
+ expect(html).toContain('<ol>')
297
+ expect(html).toContain('<li>one</li>')
298
+ })
299
+
300
+ it('renders code blocks', async () => {
301
+ const doc = Document({ children: Code({ children: 'const x = 1' }) })
302
+ const html = (await render(doc, 'html')) as string
303
+ expect(html).toContain('<pre')
304
+ expect(html).toContain('<code>')
305
+ expect(html).toContain('const x = 1')
306
+ })
307
+
308
+ it('renders divider', async () => {
309
+ const doc = Document({ children: Divider({ color: '#ccc', thickness: 2 }) })
310
+ const html = (await render(doc, 'html')) as string
311
+ expect(html).toContain('<hr')
312
+ expect(html).toContain('2px solid #ccc')
313
+ })
314
+
315
+ it('renders spacer', async () => {
316
+ const doc = Document({ children: Spacer({ height: 30 }) })
317
+ const html = (await render(doc, 'html')) as string
318
+ expect(html).toContain('height:30px')
319
+ })
320
+
321
+ it('renders button', async () => {
322
+ const doc = Document({
323
+ children: Button({
324
+ href: '/pay',
325
+ background: '#4f46e5',
326
+ color: '#fff',
327
+ children: 'Pay',
328
+ }),
329
+ })
330
+ const html = (await render(doc, 'html')) as string
331
+ expect(html).toContain('href="/pay"')
332
+ expect(html).toContain('background:#4f46e5')
333
+ expect(html).toContain('Pay')
334
+ })
335
+
336
+ it('renders blockquote', async () => {
337
+ const doc = Document({ children: Quote({ children: 'A wise quote' }) })
338
+ const html = (await render(doc, 'html')) as string
339
+ expect(html).toContain('<blockquote')
340
+ expect(html).toContain('A wise quote')
341
+ })
342
+
343
+ it('renders section with row direction', async () => {
344
+ const doc = Document({
345
+ children: Section({
346
+ direction: 'row',
347
+ gap: 20,
348
+ background: '#f5f5f5',
349
+ children: [Text({ children: 'A' }), Text({ children: 'B' })],
350
+ }),
351
+ })
352
+ const html = (await render(doc, 'html')) as string
353
+ expect(html).toContain('display:flex')
354
+ expect(html).toContain('flex-direction:row')
355
+ })
356
+
357
+ it('renders image with center alignment', async () => {
358
+ const doc = Document({
359
+ children: Image({ src: '/img.png', align: 'center' }),
360
+ })
361
+ const html = (await render(doc, 'html')) as string
362
+ expect(html).toContain('margin:0 auto')
363
+ })
364
+
365
+ it('renders table with bordered option', async () => {
366
+ const doc = Document({
367
+ children: Table({ columns: ['A'], rows: [['1']], bordered: true }),
368
+ })
369
+ const html = (await render(doc, 'html')) as string
370
+ expect(html).toContain('border:1px solid #ddd')
371
+ })
372
+
373
+ it('renders table with caption', async () => {
374
+ const doc = Document({
375
+ children: Table({ columns: ['A'], rows: [['1']], caption: 'My Table' }),
376
+ })
377
+ const html = (await render(doc, 'html')) as string
378
+ expect(html).toContain('<caption>My Table</caption>')
379
+ })
380
+
381
+ it('escapes HTML in text', async () => {
382
+ const doc = Document({
383
+ children: Text({ children: '<script>alert(1)</script>' }),
384
+ })
385
+ const html = (await render(doc, 'html')) as string
386
+ expect(html).not.toContain('<script>')
387
+ expect(html).toContain('&lt;script&gt;')
388
+ })
389
+
390
+ it('renders text with underline and strikethrough', async () => {
391
+ const ul = Document({
392
+ children: Text({ underline: true, children: 'underlined' }),
393
+ })
394
+ const st = Document({
395
+ children: Text({ strikethrough: true, children: 'struck' }),
396
+ })
397
+ expect((await render(ul, 'html')) as string).toContain(
398
+ 'text-decoration:underline',
399
+ )
400
+ expect((await render(st, 'html')) as string).toContain(
401
+ 'text-decoration:line-through',
402
+ )
403
+ })
404
+
405
+ it('renders image with right alignment', async () => {
406
+ const doc = Document({ children: Image({ src: '/x.png', align: 'right' }) })
407
+ const html = (await render(doc, 'html')) as string
408
+ expect(html).toContain('margin-left:auto')
409
+ })
410
+ })
411
+
412
+ // ─── Email Renderer ─────────────────────────────────────────────────────────
413
+
414
+ describe('email renderer', () => {
415
+ it('renders email-safe HTML', async () => {
416
+ const doc = Document({
417
+ title: 'Welcome',
418
+ children: [
419
+ Heading({ children: 'Hello!' }),
420
+ Text({ children: 'Welcome to our service.' }),
421
+ ],
422
+ })
423
+ const html = (await render(doc, 'email')) as string
424
+ expect(html).toContain('<!DOCTYPE html>')
425
+ expect(html).toContain('max-width:600px')
426
+ expect(html).toContain('Hello!')
427
+ // Should have Outlook conditional comments
428
+ expect(html).toContain('<!--[if mso]>')
429
+ })
430
+
431
+ it('renders bulletproof buttons', async () => {
432
+ const doc = Document({
433
+ children: Button({
434
+ href: '/pay',
435
+ background: '#4f46e5',
436
+ children: 'Pay Now',
437
+ }),
438
+ })
439
+ const html = (await render(doc, 'email')) as string
440
+ // VML for Outlook
441
+ expect(html).toContain('v:roundrect')
442
+ // CSS for others
443
+ expect(html).toContain('background-color:#4f46e5')
444
+ expect(html).toContain('Pay Now')
445
+ })
446
+
447
+ it('renders table with inline styles', async () => {
448
+ const doc = Document({
449
+ children: Table({
450
+ columns: ['Name', 'Price'],
451
+ rows: [['Widget', '$10']],
452
+ headerStyle: { background: '#000', color: '#fff' },
453
+ }),
454
+ })
455
+ const html = (await render(doc, 'email')) as string
456
+ expect(html).toContain('background-color:#000')
457
+ expect(html).toContain('color:#fff')
458
+ expect(html).toContain('Widget')
459
+ })
460
+
461
+ it('renders section with row direction using tables', async () => {
462
+ const doc = Document({
463
+ children: Section({
464
+ direction: 'row',
465
+ children: [Text({ children: 'Left' }), Text({ children: 'Right' })],
466
+ }),
467
+ })
468
+ const html = (await render(doc, 'email')) as string
469
+ // Should use table layout, not flexbox
470
+ expect(html).not.toContain('display:flex')
471
+ expect(html).toContain('<table')
472
+ expect(html).toContain('Left')
473
+ expect(html).toContain('Right')
474
+ })
475
+
476
+ it('renders divider using table', async () => {
477
+ const doc = Document({ children: Divider() })
478
+ const html = (await render(doc, 'email')) as string
479
+ expect(html).toContain('border-top:1px solid')
480
+ })
481
+
482
+ it('renders quote using table with border-left', async () => {
483
+ const doc = Document({ children: Quote({ children: 'A quote' }) })
484
+ const html = (await render(doc, 'email')) as string
485
+ expect(html).toContain('border-left:4px solid')
486
+ })
487
+ })
488
+
489
+ // ─── Markdown Renderer ──────────────────────────────────────────────────────
490
+
491
+ describe('markdown renderer', () => {
492
+ it('renders headings with # prefix', async () => {
493
+ const doc = Document({
494
+ children: [
495
+ Heading({ level: 1, children: 'Title' }),
496
+ Heading({ level: 3, children: 'Subtitle' }),
497
+ ],
498
+ })
499
+ const md = (await render(doc, 'md')) as string
500
+ expect(md).toContain('# Title')
501
+ expect(md).toContain('### Subtitle')
502
+ })
503
+
504
+ it('renders bold and italic text', async () => {
505
+ const doc = Document({
506
+ children: [
507
+ Text({ bold: true, children: 'bold' }),
508
+ Text({ italic: true, children: 'italic' }),
509
+ Text({ strikethrough: true, children: 'struck' }),
510
+ ],
511
+ })
512
+ const md = (await render(doc, 'md')) as string
513
+ expect(md).toContain('**bold**')
514
+ expect(md).toContain('*italic*')
515
+ expect(md).toContain('~~struck~~')
516
+ })
517
+
518
+ it('renders tables as pipe tables', async () => {
519
+ const doc = Document({
520
+ children: Table({
521
+ columns: ['Name', { header: 'Price', align: 'right' }],
522
+ rows: [['Widget', '$10']],
523
+ }),
524
+ })
525
+ const md = (await render(doc, 'md')) as string
526
+ expect(md).toContain('| Name | Price |')
527
+ expect(md).toContain('| --- | ---: |')
528
+ expect(md).toContain('| Widget | $10 |')
529
+ })
530
+
531
+ it('renders links in markdown format', async () => {
532
+ const doc = Document({
533
+ children: Link({ href: 'https://example.com', children: 'click' }),
534
+ })
535
+ const md = (await render(doc, 'md')) as string
536
+ expect(md).toContain('[click](https://example.com)')
537
+ })
538
+
539
+ it('renders images', async () => {
540
+ const doc = Document({
541
+ children: Image({ src: '/img.png', alt: 'Photo', caption: 'A photo' }),
542
+ })
543
+ const md = (await render(doc, 'md')) as string
544
+ expect(md).toContain('![Photo](/img.png)')
545
+ expect(md).toContain('*A photo*')
546
+ })
547
+
548
+ it('renders code blocks with language', async () => {
549
+ const doc = Document({
550
+ children: Code({ language: 'typescript', children: 'const x = 1' }),
551
+ })
552
+ const md = (await render(doc, 'md')) as string
553
+ expect(md).toContain('```typescript')
554
+ expect(md).toContain('const x = 1')
555
+ expect(md).toContain('```')
556
+ })
557
+
558
+ it('renders ordered and unordered lists', async () => {
559
+ const ol = Document({
560
+ children: List({
561
+ ordered: true,
562
+ children: [
563
+ ListItem({ children: 'first' }),
564
+ ListItem({ children: 'second' }),
565
+ ],
566
+ }),
567
+ })
568
+ const ul = Document({
569
+ children: List({
570
+ children: [ListItem({ children: 'a' }), ListItem({ children: 'b' })],
571
+ }),
572
+ })
573
+ const orderedMd = (await render(ol, 'md')) as string
574
+ const unorderedMd = (await render(ul, 'md')) as string
575
+ expect(orderedMd).toContain('1. first')
576
+ expect(orderedMd).toContain('2. second')
577
+ expect(unorderedMd).toContain('- a')
578
+ expect(unorderedMd).toContain('- b')
579
+ })
580
+
581
+ it('renders divider as ---', async () => {
582
+ const doc = Document({ children: Divider() })
583
+ const md = (await render(doc, 'md')) as string
584
+ expect(md).toContain('---')
585
+ })
586
+
587
+ it('renders button as link', async () => {
588
+ const doc = Document({
589
+ children: Button({ href: '/pay', children: 'Pay' }),
590
+ })
591
+ const md = (await render(doc, 'md')) as string
592
+ expect(md).toContain('[Pay](/pay)')
593
+ })
594
+
595
+ it('renders quote with >', async () => {
596
+ const doc = Document({ children: Quote({ children: 'wise' }) })
597
+ const md = (await render(doc, 'md')) as string
598
+ expect(md).toContain('> wise')
599
+ })
600
+
601
+ it('renders table with caption', async () => {
602
+ const doc = Document({
603
+ children: Table({ columns: ['A'], rows: [['1']], caption: 'My Table' }),
604
+ })
605
+ const md = (await render(doc, 'md')) as string
606
+ expect(md).toContain('*My Table*')
607
+ })
608
+ })
609
+
610
+ // ─── Text Renderer ──────────────────────────────────────────────────────────
611
+
612
+ describe('text renderer', () => {
613
+ it('renders headings with underlines', async () => {
614
+ const doc = Document({
615
+ children: [
616
+ Heading({ level: 1, children: 'Title' }),
617
+ Heading({ level: 2, children: 'Sub' }),
618
+ ],
619
+ })
620
+ const text = (await render(doc, 'text')) as string
621
+ expect(text).toContain('TITLE')
622
+ expect(text).toContain('=====')
623
+ expect(text).toContain('Sub')
624
+ expect(text).toContain('---')
625
+ })
626
+
627
+ it('renders aligned table columns', async () => {
628
+ const doc = Document({
629
+ children: Table({
630
+ columns: [
631
+ { header: 'Name', align: 'left' },
632
+ { header: 'Price', align: 'right' },
633
+ ],
634
+ rows: [['Widget', '$10']],
635
+ }),
636
+ })
637
+ const text = (await render(doc, 'text')) as string
638
+ expect(text).toContain('Name')
639
+ expect(text).toContain('Price')
640
+ expect(text).toContain('Widget')
641
+ })
642
+
643
+ it('renders button as link reference', async () => {
644
+ const doc = Document({
645
+ children: Button({ href: '/pay', children: 'Pay' }),
646
+ })
647
+ const text = (await render(doc, 'text')) as string
648
+ expect(text).toContain('[Pay]')
649
+ expect(text).toContain('/pay')
650
+ })
651
+
652
+ it('renders image as placeholder', async () => {
653
+ const doc = Document({
654
+ children: Image({ src: '/x.png', alt: 'Photo', caption: 'Nice' }),
655
+ })
656
+ const text = (await render(doc, 'text')) as string
657
+ expect(text).toContain('[Photo — Nice]')
658
+ })
659
+ })
660
+
661
+ // ─── CSV Renderer ───────────────────────────────────────────────────────────
662
+
663
+ describe('CSV renderer', () => {
664
+ it('extracts tables as CSV', async () => {
665
+ const doc = Document({
666
+ children: Table({
667
+ columns: ['Name', 'Price'],
668
+ rows: [
669
+ ['Widget', '$10'],
670
+ ['Gadget', '$20'],
671
+ ],
672
+ }),
673
+ })
674
+ const csv = (await render(doc, 'csv')) as string
675
+ expect(csv).toContain('Name,Price')
676
+ expect(csv).toContain('Widget,$10')
677
+ expect(csv).toContain('Gadget,$20')
678
+ })
679
+
680
+ it('escapes commas and quotes', async () => {
681
+ const doc = Document({
682
+ children: Table({
683
+ columns: ['Name'],
684
+ rows: [['Widget, Inc.'], ['He said "hello"']],
685
+ }),
686
+ })
687
+ const csv = (await render(doc, 'csv')) as string
688
+ expect(csv).toContain('"Widget, Inc."')
689
+ expect(csv).toContain('"He said ""hello"""')
690
+ })
691
+
692
+ it('returns message when no tables', async () => {
693
+ const doc = Document({ children: Text({ children: 'no tables here' }) })
694
+ const csv = (await render(doc, 'csv')) as string
695
+ expect(csv).toContain('No tables found')
696
+ })
697
+
698
+ it('handles multiple tables', async () => {
699
+ const doc = Document({
700
+ children: [
701
+ Table({ columns: ['A'], rows: [['1']] }),
702
+ Table({ columns: ['B'], rows: [['2']] }),
703
+ ],
704
+ })
705
+ const csv = (await render(doc, 'csv')) as string
706
+ expect(csv).toContain('A')
707
+ expect(csv).toContain('B')
708
+ })
709
+
710
+ it('adds caption as comment', async () => {
711
+ const doc = Document({
712
+ children: Table({ columns: ['A'], rows: [['1']], caption: 'My Data' }),
713
+ })
714
+ const csv = (await render(doc, 'csv')) as string
715
+ expect(csv).toContain('# My Data')
716
+ })
717
+ })
718
+
719
+ // ─── Builder Pattern ────────────────────────────────────────────────────────
720
+
721
+ describe('createDocument builder', () => {
722
+ it('builds a document with heading and text', async () => {
723
+ const doc = createDocument({ title: 'Test' })
724
+ .heading('Title')
725
+ .text('Hello world')
726
+
727
+ const node = doc.build()
728
+ expect(node.type).toBe('document')
729
+ expect(node.props.title).toBe('Test')
730
+ })
731
+
732
+ it('renders to HTML', async () => {
733
+ const doc = createDocument()
734
+ .heading('Report')
735
+ .text('Summary text')
736
+ .table({
737
+ columns: ['Name', 'Value'],
738
+ rows: [['A', '1']],
739
+ })
740
+
741
+ const html = await doc.toHtml()
742
+ expect(html).toContain('Report')
743
+ expect(html).toContain('Summary text')
744
+ expect(html).toContain('<table')
745
+ })
746
+
747
+ it('renders to markdown', async () => {
748
+ const doc = createDocument()
749
+ .heading('Title')
750
+ .text('Body', { bold: true })
751
+ .list(['item 1', 'item 2'])
752
+
753
+ const md = await doc.toMarkdown()
754
+ expect(md).toContain('# Title')
755
+ expect(md).toContain('**Body**')
756
+ expect(md).toContain('- item 1')
757
+ })
758
+
759
+ it('renders to text', async () => {
760
+ const doc = createDocument().heading('Title').text('Body')
761
+
762
+ const text = await doc.toText()
763
+ expect(text).toContain('TITLE')
764
+ expect(text).toContain('Body')
765
+ })
766
+
767
+ it('renders to CSV', async () => {
768
+ const doc = createDocument().table({ columns: ['X'], rows: [['1'], ['2']] })
769
+
770
+ const csv = await doc.toCsv()
771
+ expect(csv).toContain('X')
772
+ expect(csv).toContain('1')
773
+ })
774
+
775
+ it('supports all builder methods', () => {
776
+ const doc = createDocument()
777
+ .heading('H')
778
+ .text('T')
779
+ .paragraph('P')
780
+ .image('/img.png')
781
+ .table({ columns: ['A'], rows: [['1']] })
782
+ .list(['a', 'b'])
783
+ .code('x = 1', { language: 'python' })
784
+ .divider()
785
+ .spacer(20)
786
+ .quote('Q')
787
+ .button('Click', { href: '/go' })
788
+ .link('Link', { href: '/link' })
789
+
790
+ const node = doc.build()
791
+ expect(node.type).toBe('document')
792
+ })
793
+
794
+ it('chart without instance shows placeholder', async () => {
795
+ const doc = createDocument().chart(null)
796
+
797
+ const html = await doc.toHtml()
798
+ expect(html).toContain('[Chart]')
799
+ })
800
+
801
+ it('flow without instance shows placeholder', async () => {
802
+ const doc = createDocument().flow(null)
803
+
804
+ const html = await doc.toHtml()
805
+ expect(html).toContain('[Flow Diagram]')
806
+ })
807
+
808
+ it('chart with getDataURL captures image', async () => {
809
+ const mockChart = {
810
+ getDataURL: () => 'data:image/png;base64,abc123',
811
+ }
812
+ const doc = createDocument().chart(mockChart, { width: 400 })
813
+ const html = await doc.toHtml()
814
+ expect(html).toContain('data:image/png;base64,abc123')
815
+ })
816
+
817
+ it('flow with toSVG captures image', async () => {
818
+ const mockFlow = {
819
+ toSVG: () => '<svg><rect/></svg>',
820
+ }
821
+ const doc = createDocument().flow(mockFlow, { width: 500 })
822
+ const html = await doc.toHtml()
823
+ expect(html).toContain('data:image/svg+xml')
824
+ })
825
+ })
826
+
827
+ // ─── Custom Renderers ───────────────────────────────────────────────────────
828
+
829
+ describe('custom renderers', () => {
830
+ it('registerRenderer adds a custom format', async () => {
831
+ registerRenderer('custom', {
832
+ async render(node) {
833
+ return `CUSTOM:${node.type}`
834
+ },
835
+ })
836
+
837
+ const doc = Document({ children: 'hello' })
838
+ const result = await render(doc, 'custom')
839
+ expect(result).toBe('CUSTOM:document')
840
+ })
841
+
842
+ it('unregisterRenderer removes a format', () => {
843
+ registerRenderer('temp', {
844
+ async render() {
845
+ return 'x'
846
+ },
847
+ })
848
+ unregisterRenderer('temp')
849
+ expect(render(Document({ children: 'x' }), 'temp')).rejects.toThrow(
850
+ 'No renderer registered',
851
+ )
852
+ })
853
+
854
+ it('throws for unknown format', () => {
855
+ expect(render(Document({ children: 'x' }), 'unknown')).rejects.toThrow(
856
+ 'No renderer registered',
857
+ )
858
+ })
859
+
860
+ it('lazy renderer is cached after first use', async () => {
861
+ let loadCount = 0
862
+ registerRenderer('lazy', async () => {
863
+ loadCount++
864
+ return {
865
+ async render() {
866
+ return 'lazy-result'
867
+ },
868
+ }
869
+ })
870
+
871
+ await render(Document({ children: 'x' }), 'lazy')
872
+ await render(Document({ children: 'x' }), 'lazy')
873
+ expect(loadCount).toBe(1)
874
+ })
875
+ })
876
+
877
+ // ─── Real-World Document ────────────────────────────────────────────────────
878
+
879
+ describe('real-world document', () => {
880
+ function createInvoice() {
881
+ return Document({
882
+ title: 'Invoice #1234',
883
+ author: 'Acme Corp',
884
+ children: Page({
885
+ size: 'A4',
886
+ margin: 40,
887
+ children: [
888
+ Row({
889
+ gap: 20,
890
+ children: [
891
+ Column({
892
+ children: Image({ src: '/logo.png', width: 80, alt: 'Logo' }),
893
+ }),
894
+ Column({
895
+ children: [
896
+ Heading({ children: 'Invoice #1234' }),
897
+ Text({ color: '#666', children: 'March 23, 2026' }),
898
+ ],
899
+ }),
900
+ ],
901
+ }),
902
+ Spacer({ height: 30 }),
903
+ Table({
904
+ columns: [
905
+ { header: 'Item', width: '50%' },
906
+ { header: 'Qty', width: '15%', align: 'center' },
907
+ { header: 'Price', width: '15%', align: 'right' },
908
+ { header: 'Total', width: '20%', align: 'right' },
909
+ ],
910
+ rows: [
911
+ ['Widget Pro', '2', '$50', '$100'],
912
+ ['Gadget Plus', '1', '$75', '$75'],
913
+ ['Service Fee', '1', '$25', '$25'],
914
+ ],
915
+ striped: true,
916
+ headerStyle: { background: '#1a1a2e', color: '#fff' },
917
+ }),
918
+ Spacer({ height: 20 }),
919
+ Text({
920
+ bold: true,
921
+ align: 'right',
922
+ size: 18,
923
+ children: 'Total: $200',
924
+ }),
925
+ Divider(),
926
+ Text({
927
+ color: '#999',
928
+ size: 12,
929
+ children: 'Thank you for your business!',
930
+ }),
931
+ Button({
932
+ href: 'https://acme.com/pay/1234',
933
+ background: '#4f46e5',
934
+ align: 'center',
935
+ children: 'Pay Now',
936
+ }),
937
+ ],
938
+ }),
939
+ })
940
+ }
941
+
942
+ it('renders as HTML', async () => {
943
+ const html = (await render(createInvoice(), 'html')) as string
944
+ expect(html).toContain('Invoice #1234')
945
+ expect(html).toContain('Widget Pro')
946
+ expect(html).toContain('Total: $200')
947
+ expect(html).toContain('Pay Now')
948
+ })
949
+
950
+ it('renders as email', async () => {
951
+ const html = (await render(createInvoice(), 'email')) as string
952
+ expect(html).toContain('Invoice #1234')
953
+ expect(html).toContain('max-width:600px')
954
+ expect(html).toContain('v:roundrect') // Outlook button
955
+ })
956
+
957
+ it('renders as markdown', async () => {
958
+ const md = (await render(createInvoice(), 'md')) as string
959
+ expect(md).toContain('# Invoice #1234')
960
+ expect(md).toContain('| Widget Pro')
961
+ expect(md).toContain('**Total: $200**')
962
+ })
963
+
964
+ it('renders as text', async () => {
965
+ const text = (await render(createInvoice(), 'text')) as string
966
+ expect(text).toContain('INVOICE #1234')
967
+ expect(text).toContain('Widget Pro')
968
+ expect(text).toContain('Total: $200')
969
+ })
970
+
971
+ it('renders as CSV', async () => {
972
+ const csv = (await render(createInvoice(), 'csv')) as string
973
+ expect(csv).toContain('Item,Qty,Price,Total')
974
+ expect(csv).toContain('Widget Pro,2,$50,$100')
975
+ })
976
+ })
977
+
978
+ // ─── Text Renderer — additional coverage ────────────────────────────────────
979
+
980
+ describe('text renderer — additional', () => {
981
+ it('renders link with URL', async () => {
982
+ const doc = Document({
983
+ children: Link({ href: 'https://x.com', children: 'Link' }),
984
+ })
985
+ const text = (await render(doc, 'text')) as string
986
+ expect(text).toContain('Link (https://x.com)')
987
+ })
988
+
989
+ it('renders table with caption', async () => {
990
+ const doc = Document({
991
+ children: Table({ columns: ['A'], rows: [['1']], caption: 'Data' }),
992
+ })
993
+ const text = (await render(doc, 'text')) as string
994
+ expect(text).toContain('Data')
995
+ })
996
+
997
+ it('renders ordered list', async () => {
998
+ const doc = Document({
999
+ children: List({
1000
+ ordered: true,
1001
+ children: [
1002
+ ListItem({ children: 'one' }),
1003
+ ListItem({ children: 'two' }),
1004
+ ],
1005
+ }),
1006
+ })
1007
+ const text = (await render(doc, 'text')) as string
1008
+ expect(text).toContain('1. one')
1009
+ expect(text).toContain('2. two')
1010
+ })
1011
+
1012
+ it('renders unordered list', async () => {
1013
+ const doc = Document({
1014
+ children: List({
1015
+ children: [ListItem({ children: 'a' }), ListItem({ children: 'b' })],
1016
+ }),
1017
+ })
1018
+ const text = (await render(doc, 'text')) as string
1019
+ expect(text).toContain('* a')
1020
+ expect(text).toContain('* b')
1021
+ })
1022
+
1023
+ it('renders code block', async () => {
1024
+ const doc = Document({ children: Code({ children: 'x = 1' }) })
1025
+ const text = (await render(doc, 'text')) as string
1026
+ expect(text).toContain('x = 1')
1027
+ })
1028
+
1029
+ it('renders divider', async () => {
1030
+ const doc = Document({ children: Divider() })
1031
+ const text = (await render(doc, 'text')) as string
1032
+ expect(text).toContain('─')
1033
+ })
1034
+
1035
+ it('renders spacer as newline', async () => {
1036
+ const doc = Document({
1037
+ children: [
1038
+ Text({ children: 'A' }),
1039
+ Spacer({ height: 20 }),
1040
+ Text({ children: 'B' }),
1041
+ ],
1042
+ })
1043
+ const text = (await render(doc, 'text')) as string
1044
+ expect(text).toContain('A')
1045
+ expect(text).toContain('B')
1046
+ })
1047
+
1048
+ it('renders quote with indentation', async () => {
1049
+ const doc = Document({ children: Quote({ children: 'wise' }) })
1050
+ const text = (await render(doc, 'text')) as string
1051
+ expect(text).toContain('"wise"')
1052
+ })
1053
+
1054
+ it('renders section/row/column', async () => {
1055
+ const doc = Document({
1056
+ children: Section({
1057
+ children: Row({
1058
+ children: Column({ children: Text({ children: 'nested' }) }),
1059
+ }),
1060
+ }),
1061
+ })
1062
+ const text = (await render(doc, 'text')) as string
1063
+ expect(text).toContain('nested')
1064
+ })
1065
+
1066
+ it('renders heading level 3+', async () => {
1067
+ const doc = Document({ children: Heading({ level: 4, children: 'Sub' }) })
1068
+ const text = (await render(doc, 'text')) as string
1069
+ expect(text).toContain('Sub')
1070
+ // Level 3+ should not have underline
1071
+ expect(text).not.toContain('===')
1072
+ expect(text).not.toContain('---')
1073
+ })
1074
+
1075
+ it('renders table with center aligned column', async () => {
1076
+ const doc = Document({
1077
+ children: Table({
1078
+ columns: [{ header: 'Name', align: 'center' }],
1079
+ rows: [['X']],
1080
+ }),
1081
+ })
1082
+ const text = (await render(doc, 'text')) as string
1083
+ expect(text).toContain('Name')
1084
+ expect(text).toContain('X')
1085
+ })
1086
+ })
1087
+
1088
+ // ─── Builder — additional coverage ───────────────────────────────────────────
1089
+
1090
+ describe('builder — additional', () => {
1091
+ it('pageBreak wraps content', () => {
1092
+ const doc = createDocument().heading('Page 1').pageBreak().heading('Page 2')
1093
+ const node = doc.build()
1094
+ expect(node.type).toBe('document')
1095
+ })
1096
+
1097
+ it('toEmail renders', async () => {
1098
+ const html = await createDocument().heading('Hi').toEmail()
1099
+ expect(html).toContain('Hi')
1100
+ expect(html).toContain('max-width:600px')
1101
+ })
1102
+
1103
+ it('toCsv renders', async () => {
1104
+ const csv = await createDocument()
1105
+ .table({ columns: ['X'], rows: [['1']] })
1106
+ .toCsv()
1107
+ expect(csv).toContain('X')
1108
+ })
1109
+
1110
+ it('toText renders', async () => {
1111
+ const text = await createDocument().heading('Hi').toText()
1112
+ expect(text).toContain('HI')
1113
+ })
1114
+ })
1115
+
1116
+ // ─── Markdown — additional branch coverage ──────────────────────────────────
1117
+
1118
+ describe('markdown — additional branches', () => {
1119
+ it('renders table with center aligned column', async () => {
1120
+ const doc = Document({
1121
+ children: Table({
1122
+ columns: [{ header: 'X', align: 'center' }],
1123
+ rows: [['1']],
1124
+ }),
1125
+ })
1126
+ const md = (await render(doc, 'md')) as string
1127
+ expect(md).toContain(':---:')
1128
+ })
1129
+
1130
+ it('renders table with left aligned column (default)', async () => {
1131
+ const doc = Document({
1132
+ children: Table({
1133
+ columns: [{ header: 'X', align: 'left' }],
1134
+ rows: [['1']],
1135
+ }),
1136
+ })
1137
+ const md = (await render(doc, 'md')) as string
1138
+ expect(md).toContain('| --- |')
1139
+ })
1140
+
1141
+ it('renders empty table gracefully', async () => {
1142
+ const doc = Document({
1143
+ children: Table({ columns: [], rows: [] }),
1144
+ })
1145
+ const md = (await render(doc, 'md')) as string
1146
+ expect(md).toBeDefined()
1147
+ })
1148
+
1149
+ it('renders image without caption', async () => {
1150
+ const doc = Document({ children: Image({ src: '/x.png', alt: 'X' }) })
1151
+ const md = (await render(doc, 'md')) as string
1152
+ expect(md).toContain('![X](/x.png)')
1153
+ expect(md).not.toContain('*')
1154
+ })
1155
+
1156
+ it('renders image without alt', async () => {
1157
+ const doc = Document({ children: Image({ src: '/x.png' }) })
1158
+ const md = (await render(doc, 'md')) as string
1159
+ expect(md).toContain('![](/x.png)')
1160
+ })
1161
+
1162
+ it('renders code without language', async () => {
1163
+ const doc = Document({ children: Code({ children: 'x = 1' }) })
1164
+ const md = (await render(doc, 'md')) as string
1165
+ expect(md).toContain('```\nx = 1\n```')
1166
+ })
1167
+
1168
+ it('renders spacer as newline', async () => {
1169
+ const doc = Document({ children: Spacer({ height: 20 }) })
1170
+ const md = (await render(doc, 'md')) as string
1171
+ expect(md).toBeDefined()
1172
+ })
1173
+ })
1174
+
1175
+ // ─── Email — additional branch coverage ─────────────────────────────────────
1176
+
1177
+ describe('email — additional branches', () => {
1178
+ it('renders section with background and padding', async () => {
1179
+ const doc = Document({
1180
+ children: Section({
1181
+ background: '#f00',
1182
+ padding: [10, 20],
1183
+ borderRadius: 8,
1184
+ children: Text({ children: 'hi' }),
1185
+ }),
1186
+ })
1187
+ const html = (await render(doc, 'email')) as string
1188
+ expect(html).toContain('background-color:#f00')
1189
+ expect(html).toContain('border-radius:8px')
1190
+ })
1191
+
1192
+ it('renders image with right alignment', async () => {
1193
+ const doc = Document({ children: Image({ src: '/x.png', align: 'right' }) })
1194
+ const html = (await render(doc, 'email')) as string
1195
+ expect(html).toContain('text-align:right')
1196
+ })
1197
+
1198
+ it('renders striped table', async () => {
1199
+ const doc = Document({
1200
+ children: Table({
1201
+ columns: ['A'],
1202
+ rows: [['1'], ['2'], ['3']],
1203
+ striped: true,
1204
+ }),
1205
+ })
1206
+ const html = (await render(doc, 'email')) as string
1207
+ expect(html).toContain('background-color:#f9f9f9')
1208
+ })
1209
+
1210
+ it('renders heading level 2', async () => {
1211
+ const doc = Document({ children: Heading({ level: 2, children: 'Sub' }) })
1212
+ const html = (await render(doc, 'email')) as string
1213
+ expect(html).toContain('<h2')
1214
+ expect(html).toContain('font-size:24px')
1215
+ })
1216
+
1217
+ it('renders text with all formatting options', async () => {
1218
+ const doc = Document({
1219
+ children: Text({
1220
+ size: 16,
1221
+ bold: true,
1222
+ italic: true,
1223
+ underline: true,
1224
+ align: 'center',
1225
+ lineHeight: 2,
1226
+ children: 'styled',
1227
+ }),
1228
+ })
1229
+ const html = (await render(doc, 'email')) as string
1230
+ expect(html).toContain('font-size:16px')
1231
+ expect(html).toContain('font-weight:bold')
1232
+ expect(html).toContain('font-style:italic')
1233
+ expect(html).toContain('text-decoration:underline')
1234
+ expect(html).toContain('text-align:center')
1235
+ })
1236
+
1237
+ it('renders text with strikethrough', async () => {
1238
+ const doc = Document({
1239
+ children: Text({ strikethrough: true, children: 'old' }),
1240
+ })
1241
+ const html = (await render(doc, 'email')) as string
1242
+ expect(html).toContain('text-decoration:line-through')
1243
+ })
1244
+
1245
+ it('renders button with custom alignment', async () => {
1246
+ const doc = Document({
1247
+ children: Button({ href: '/x', align: 'center', children: 'Go' }),
1248
+ })
1249
+ const html = (await render(doc, 'email')) as string
1250
+ expect(html).toContain('text-align:center')
1251
+ })
1252
+
1253
+ it('renders section column direction (default)', async () => {
1254
+ const doc = Document({
1255
+ children: Section({ children: Text({ children: 'content' }) }),
1256
+ })
1257
+ const html = (await render(doc, 'email')) as string
1258
+ expect(html).toContain('content')
1259
+ })
1260
+
1261
+ it('renders section with gap in row', async () => {
1262
+ const doc = Document({
1263
+ children: Section({
1264
+ direction: 'row',
1265
+ gap: 16,
1266
+ children: [Text({ children: 'a' }), Text({ children: 'b' })],
1267
+ }),
1268
+ })
1269
+ const html = (await render(doc, 'email')) as string
1270
+ expect(html).toContain('padding:0 8px')
1271
+ })
1272
+ })
1273
+
1274
+ // ─── HTML — additional branch coverage ──────────────────────────────────────
1275
+
1276
+ describe('html — additional branches', () => {
1277
+ it('renders section column direction (default)', async () => {
1278
+ const doc = Document({
1279
+ children: Section({ children: Text({ children: 'x' }) }),
1280
+ })
1281
+ const html = (await render(doc, 'html')) as string
1282
+ expect(html).not.toContain('display:flex')
1283
+ })
1284
+
1285
+ it('renders page with margin as array', async () => {
1286
+ const doc = Document({
1287
+ children: Page({ margin: [10, 20], children: Text({ children: 'hi' }) }),
1288
+ })
1289
+ const html = (await render(doc, 'html')) as string
1290
+ expect(html).toContain('10px 20px')
1291
+ })
1292
+
1293
+ it('renders page with 4-value margin', async () => {
1294
+ const doc = Document({
1295
+ children: Page({
1296
+ margin: [10, 20, 30, 40],
1297
+ children: Text({ children: 'hi' }),
1298
+ }),
1299
+ })
1300
+ const html = (await render(doc, 'html')) as string
1301
+ expect(html).toContain('10px 20px 30px 40px')
1302
+ })
1303
+
1304
+ it('renders text with lineHeight', async () => {
1305
+ const doc = Document({
1306
+ children: Text({ lineHeight: 1.8, children: 'text' }),
1307
+ })
1308
+ const html = (await render(doc, 'html')) as string
1309
+ expect(html).toContain('line-height:1.8')
1310
+ })
1311
+ })
1312
+
1313
+ // ─── CSV — additional branch coverage ───────────────────────────────────────
1314
+
1315
+ describe('csv — additional branches', () => {
1316
+ it('finds tables nested in pages', async () => {
1317
+ const doc = Document({
1318
+ children: Page({
1319
+ children: Section({
1320
+ children: Table({ columns: ['Nested'], rows: [['val']] }),
1321
+ }),
1322
+ }),
1323
+ })
1324
+ const csv = (await render(doc, 'csv')) as string
1325
+ expect(csv).toContain('Nested')
1326
+ expect(csv).toContain('val')
1327
+ })
1328
+ })
1329
+
1330
+ // ─── Render Dispatcher — additional coverage ────────────────────────────────
1331
+
1332
+ describe('render dispatcher — additional', () => {
1333
+ it('error message includes available formats', async () => {
1334
+ try {
1335
+ await render(Document({ children: 'x' }), 'nonexistent')
1336
+ } catch (e) {
1337
+ expect((e as Error).message).toContain('No renderer registered')
1338
+ expect((e as Error).message).toContain('Available:')
1339
+ expect((e as Error).message).toContain('html')
1340
+ }
1341
+ })
1342
+ })
1343
+
1344
+ // ─── Email Renderer — additional coverage ───────────────────────────────────
1345
+
1346
+ describe('email renderer — additional', () => {
1347
+ it('renders image with caption', async () => {
1348
+ const doc = Document({
1349
+ children: Image({ src: '/x.png', alt: 'Photo', caption: 'Nice' }),
1350
+ })
1351
+ const html = (await render(doc, 'email')) as string
1352
+ expect(html).toContain('Nice')
1353
+ })
1354
+
1355
+ it('renders image with center alignment', async () => {
1356
+ const doc = Document({
1357
+ children: Image({ src: '/x.png', align: 'center' }),
1358
+ })
1359
+ const html = (await render(doc, 'email')) as string
1360
+ expect(html).toContain('text-align:center')
1361
+ })
1362
+
1363
+ it('renders code block', async () => {
1364
+ const doc = Document({ children: Code({ children: 'const x = 1' }) })
1365
+ const html = (await render(doc, 'email')) as string
1366
+ expect(html).toContain('Courier New')
1367
+ expect(html).toContain('const x = 1')
1368
+ })
1369
+
1370
+ it('renders spacer with line-height trick', async () => {
1371
+ const doc = Document({ children: Spacer({ height: 20 }) })
1372
+ const html = (await render(doc, 'email')) as string
1373
+ expect(html).toContain('height:20px')
1374
+ expect(html).toContain('line-height:20px')
1375
+ })
1376
+
1377
+ it('renders list', async () => {
1378
+ const doc = Document({
1379
+ children: List({
1380
+ children: [
1381
+ ListItem({ children: 'one' }),
1382
+ ListItem({ children: 'two' }),
1383
+ ],
1384
+ }),
1385
+ })
1386
+ const html = (await render(doc, 'email')) as string
1387
+ expect(html).toContain('<ul')
1388
+ expect(html).toContain('<li')
1389
+ })
1390
+
1391
+ it('renders table caption', async () => {
1392
+ const doc = Document({
1393
+ children: Table({ columns: ['A'], rows: [['1']], caption: 'Data' }),
1394
+ })
1395
+ const html = (await render(doc, 'email')) as string
1396
+ expect(html).toContain('Data')
1397
+ })
1398
+
1399
+ it('renders link with target _blank', async () => {
1400
+ const doc = Document({
1401
+ children: Link({ href: 'https://x.com', children: 'X' }),
1402
+ })
1403
+ const html = (await render(doc, 'email')) as string
1404
+ expect(html).toContain('target="_blank"')
1405
+ })
1406
+
1407
+ it('renders row layout using tables', async () => {
1408
+ const doc = Document({
1409
+ children: Row({
1410
+ gap: 10,
1411
+ children: [Text({ children: 'L' }), Text({ children: 'R' })],
1412
+ }),
1413
+ })
1414
+ const html = (await render(doc, 'email')) as string
1415
+ expect(html).toContain('<table')
1416
+ expect(html).toContain('valign="top"')
1417
+ })
1418
+ })
1419
+
1420
+ // ─── DOCX Renderer (integration) ────────────────────────────────────────────
1421
+
1422
+ describe('DOCX renderer', () => {
1423
+ it('renders a document with heading, text, table, list, code, divider to a valid Uint8Array', async () => {
1424
+ const doc = Document({
1425
+ title: 'DOCX Test',
1426
+ author: 'Test Suite',
1427
+ children: Page({
1428
+ size: 'A4',
1429
+ margin: 40,
1430
+ children: [
1431
+ Heading({ children: 'DOCX Integration Test' }),
1432
+ Text({ children: 'A test paragraph.', bold: true }),
1433
+ Table({
1434
+ columns: ['Name', 'Value'],
1435
+ rows: [
1436
+ ['Alpha', '100'],
1437
+ ['Beta', '200'],
1438
+ ],
1439
+ striped: true,
1440
+ headerStyle: { background: '#333333', color: '#ffffff' },
1441
+ }),
1442
+ List({
1443
+ ordered: true,
1444
+ children: [
1445
+ ListItem({ children: 'First' }),
1446
+ ListItem({ children: 'Second' }),
1447
+ ],
1448
+ }),
1449
+ Code({ children: 'const x = 42' }),
1450
+ Divider(),
1451
+ ],
1452
+ }),
1453
+ })
1454
+
1455
+ const result = await render(doc, 'docx')
1456
+ expect(result).toBeInstanceOf(Uint8Array)
1457
+ expect((result as Uint8Array).length).toBeGreaterThan(0)
1458
+ // DOCX files are ZIP archives — first two bytes are PK (0x50, 0x4B)
1459
+ expect((result as Uint8Array)[0]).toBe(0x50)
1460
+ expect((result as Uint8Array)[1]).toBe(0x4b)
1461
+ }, 15000)
1462
+
1463
+ it('embeds base64 images via ImageRun', async () => {
1464
+ // 1x1 red pixel PNG as base64
1465
+ const redPixel =
1466
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='
1467
+
1468
+ const doc = Document({
1469
+ children: Page({
1470
+ children: [
1471
+ Image({ src: redPixel, width: 50, height: 50, caption: 'Red pixel' }),
1472
+ ],
1473
+ }),
1474
+ })
1475
+
1476
+ const result = await render(doc, 'docx')
1477
+ expect(result).toBeInstanceOf(Uint8Array)
1478
+ expect((result as Uint8Array).length).toBeGreaterThan(0)
1479
+ }, 15000)
1480
+
1481
+ it('renders external URL images as placeholders', async () => {
1482
+ const doc = Document({
1483
+ children: Image({
1484
+ src: 'https://example.com/logo.png',
1485
+ alt: 'Logo',
1486
+ caption: 'Company',
1487
+ }),
1488
+ })
1489
+
1490
+ const result = await render(doc, 'docx')
1491
+ expect(result).toBeInstanceOf(Uint8Array)
1492
+ expect((result as Uint8Array).length).toBeGreaterThan(0)
1493
+ }, 15000)
1494
+
1495
+ it('renders page with header and footer', async () => {
1496
+ const doc = Document({
1497
+ children: Page({
1498
+ header: Text({ children: 'My Header' }),
1499
+ footer: Text({ children: 'Page Footer' }),
1500
+ children: [
1501
+ Heading({ children: 'Content' }),
1502
+ Text({ children: 'Body text.' }),
1503
+ ],
1504
+ }),
1505
+ })
1506
+
1507
+ const result = await render(doc, 'docx')
1508
+ expect(result).toBeInstanceOf(Uint8Array)
1509
+ expect((result as Uint8Array).length).toBeGreaterThan(0)
1510
+ }, 15000)
1511
+
1512
+ it('renders table with bordered option and column widths', async () => {
1513
+ const doc = Document({
1514
+ children: Table({
1515
+ columns: [
1516
+ { header: 'Name', width: '60%' },
1517
+ { header: 'Price', width: '40%', align: 'right' },
1518
+ ],
1519
+ rows: [['Widget', '$10']],
1520
+ bordered: true,
1521
+ }),
1522
+ })
1523
+
1524
+ const result = await render(doc, 'docx')
1525
+ expect(result).toBeInstanceOf(Uint8Array)
1526
+ expect((result as Uint8Array).length).toBeGreaterThan(0)
1527
+ }, 15000)
1528
+
1529
+ it('renders nested lists', async () => {
1530
+ const doc = Document({
1531
+ children: List({
1532
+ children: [
1533
+ ListItem({
1534
+ children: [
1535
+ 'Parent item',
1536
+ List({
1537
+ children: [ListItem({ children: 'Child item' })],
1538
+ }),
1539
+ ],
1540
+ }),
1541
+ ],
1542
+ }),
1543
+ })
1544
+
1545
+ const result = await render(doc, 'docx')
1546
+ expect(result).toBeInstanceOf(Uint8Array)
1547
+ expect((result as Uint8Array).length).toBeGreaterThan(0)
1548
+ }, 15000)
1549
+ })
1550
+
1551
+ // ─── XLSX Renderer (integration) ────────────────────────────────────────────
1552
+
1553
+ describe('XLSX renderer', () => {
1554
+ it('renders a document with tables to a valid Uint8Array', async () => {
1555
+ const doc = Document({
1556
+ title: 'XLSX Test',
1557
+ author: 'Test Suite',
1558
+ children: Page({
1559
+ children: [
1560
+ Heading({ children: 'Sales Data' }),
1561
+ Table({
1562
+ columns: ['Product', 'Revenue', 'Margin'],
1563
+ rows: [
1564
+ ['Widget', '$1,234.56', '15%'],
1565
+ ['Gadget', '$2,500.00', '22.5%'],
1566
+ ],
1567
+ striped: true,
1568
+ headerStyle: { background: '#1a1a2e', color: '#ffffff' },
1569
+ }),
1570
+ ],
1571
+ }),
1572
+ })
1573
+
1574
+ const result = await render(doc, 'xlsx')
1575
+ expect(result).toBeInstanceOf(Uint8Array)
1576
+ expect((result as Uint8Array).length).toBeGreaterThan(0)
1577
+ // XLSX files are ZIP archives — first two bytes are PK (0x50, 0x4B)
1578
+ expect((result as Uint8Array)[0]).toBe(0x50)
1579
+ expect((result as Uint8Array)[1]).toBe(0x4b)
1580
+ }, 15000)
1581
+
1582
+ it('parses currency values as numbers', async () => {
1583
+ const doc = Document({
1584
+ children: Table({
1585
+ columns: ['Amount'],
1586
+ rows: [['$1,234.56'], ['$500'], ['-$100.50']],
1587
+ }),
1588
+ })
1589
+
1590
+ const result = await render(doc, 'xlsx')
1591
+ expect(result).toBeInstanceOf(Uint8Array)
1592
+ expect((result as Uint8Array).length).toBeGreaterThan(0)
1593
+ }, 15000)
1594
+
1595
+ it('parses percentage values', async () => {
1596
+ const doc = Document({
1597
+ children: Table({
1598
+ columns: ['Rate'],
1599
+ rows: [['45%'], ['12.5%'], ['-3%']],
1600
+ }),
1601
+ })
1602
+
1603
+ const result = await render(doc, 'xlsx')
1604
+ expect(result).toBeInstanceOf(Uint8Array)
1605
+ expect((result as Uint8Array).length).toBeGreaterThan(0)
1606
+ }, 15000)
1607
+
1608
+ it('renders multiple tables on the same sheet with spacing', async () => {
1609
+ const doc = Document({
1610
+ children: [
1611
+ Heading({ children: 'Report' }),
1612
+ Table({
1613
+ columns: ['A', 'B'],
1614
+ rows: [
1615
+ ['1', '2'],
1616
+ ['3', '4'],
1617
+ ],
1618
+ caption: 'First Table',
1619
+ }),
1620
+ Table({
1621
+ columns: ['X', 'Y'],
1622
+ rows: [['a', 'b']],
1623
+ caption: 'Second Table',
1624
+ }),
1625
+ ],
1626
+ })
1627
+
1628
+ const result = await render(doc, 'xlsx')
1629
+ expect(result).toBeInstanceOf(Uint8Array)
1630
+ expect((result as Uint8Array).length).toBeGreaterThan(0)
1631
+ }, 15000)
1632
+
1633
+ it('renders bordered tables', async () => {
1634
+ const doc = Document({
1635
+ children: Table({
1636
+ columns: ['Name', 'Value'],
1637
+ rows: [['Alpha', '100']],
1638
+ bordered: true,
1639
+ }),
1640
+ })
1641
+
1642
+ const result = await render(doc, 'xlsx')
1643
+ expect(result).toBeInstanceOf(Uint8Array)
1644
+ expect((result as Uint8Array).length).toBeGreaterThan(0)
1645
+ }, 15000)
1646
+
1647
+ it('renders empty document with default sheet', async () => {
1648
+ const doc = Document({ children: Text({ children: 'no tables' }) })
1649
+
1650
+ const result = await render(doc, 'xlsx')
1651
+ expect(result).toBeInstanceOf(Uint8Array)
1652
+ expect((result as Uint8Array).length).toBeGreaterThan(0)
1653
+ }, 15000)
1654
+ })
1655
+
1656
+ // ─── PDF Renderer (integration) ─────────────────────────────────────────────
1657
+
1658
+ describe('PDF renderer', () => {
1659
+ it('renders a document with heading, text, table, and data: image to a valid Uint8Array', async () => {
1660
+ // 1x1 red pixel PNG as base64
1661
+ const redPixel =
1662
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='
1663
+
1664
+ const doc = Document({
1665
+ title: 'PDF Test',
1666
+ author: 'Test Suite',
1667
+ children: Page({
1668
+ size: 'A4',
1669
+ margin: 40,
1670
+ children: [
1671
+ Heading({ children: 'PDF Integration Test' }),
1672
+ Text({ children: 'This is a test paragraph.', bold: true }),
1673
+ Table({
1674
+ columns: ['Name', 'Value'],
1675
+ rows: [
1676
+ ['Alpha', '100'],
1677
+ ['Beta', '200'],
1678
+ ],
1679
+ striped: true,
1680
+ headerStyle: { background: '#333333', color: '#ffffff' },
1681
+ }),
1682
+ Image({ src: redPixel, width: 50, height: 50 }),
1683
+ List({
1684
+ ordered: true,
1685
+ children: [
1686
+ ListItem({ children: 'First' }),
1687
+ ListItem({ children: 'Second' }),
1688
+ ],
1689
+ }),
1690
+ Divider(),
1691
+ Quote({ children: 'A wise quote.' }),
1692
+ ],
1693
+ }),
1694
+ })
1695
+
1696
+ const result = await render(doc, 'pdf')
1697
+ expect(result).toBeInstanceOf(Uint8Array)
1698
+ expect((result as Uint8Array).length).toBeGreaterThan(0)
1699
+ // PDF files start with %PDF
1700
+ const header = String.fromCharCode(...(result as Uint8Array).slice(0, 5))
1701
+ expect(header).toBe('%PDF-')
1702
+ }, 15000)
1703
+
1704
+ it('renders images with HTTP URLs as placeholder text', async () => {
1705
+ const doc = Document({
1706
+ title: 'HTTP Image Test',
1707
+ children: Page({
1708
+ children: [
1709
+ Heading({ children: 'Test' }),
1710
+ Image({ src: 'https://example.com/image.png', width: 100 }),
1711
+ ],
1712
+ }),
1713
+ })
1714
+
1715
+ const result = await render(doc, 'pdf')
1716
+ expect(result).toBeInstanceOf(Uint8Array)
1717
+ expect((result as Uint8Array).length).toBeGreaterThan(0)
1718
+ }, 15000)
1719
+
1720
+ it('renders page with header and footer', async () => {
1721
+ const doc = Document({
1722
+ title: 'Header/Footer Test',
1723
+ children: Page({
1724
+ header: Text({ children: 'Page Header', bold: true }),
1725
+ footer: Text({ children: 'Page Footer', size: 10 }),
1726
+ children: [
1727
+ Heading({ children: 'Content' }),
1728
+ Text({ children: 'Body text.' }),
1729
+ ],
1730
+ }),
1731
+ })
1732
+
1733
+ const result = await render(doc, 'pdf')
1734
+ expect(result).toBeInstanceOf(Uint8Array)
1735
+ expect((result as Uint8Array).length).toBeGreaterThan(0)
1736
+ }, 15000)
1737
+ })
1738
+
1739
+ // ─── PPTX Renderer (integration) ────────────────────────────────────────────
1740
+
1741
+ describe('PPTX renderer', () => {
1742
+ it('renders a document with pages, headings, text, and tables to a valid Uint8Array', async () => {
1743
+ const doc = Document({
1744
+ title: 'PPTX Test',
1745
+ author: 'Test Suite',
1746
+ children: [
1747
+ Page({
1748
+ children: [
1749
+ Heading({ children: 'Slide 1 Title' }),
1750
+ Text({ children: 'Introduction text.', bold: true }),
1751
+ List({
1752
+ children: [
1753
+ ListItem({ children: 'Point A' }),
1754
+ ListItem({ children: 'Point B' }),
1755
+ ],
1756
+ }),
1757
+ ],
1758
+ }),
1759
+ Page({
1760
+ children: [
1761
+ Heading({ level: 2, children: 'Slide 2 Data' }),
1762
+ Table({
1763
+ columns: ['Metric', 'Value'],
1764
+ rows: [
1765
+ ['Revenue', '$1M'],
1766
+ ['Profit', '$300K'],
1767
+ ],
1768
+ headerStyle: { background: '#1a1a2e', color: '#ffffff' },
1769
+ striped: true,
1770
+ }),
1771
+ ],
1772
+ }),
1773
+ ],
1774
+ })
1775
+
1776
+ const result = await render(doc, 'pptx')
1777
+ expect(result).toBeInstanceOf(Uint8Array)
1778
+ expect((result as Uint8Array).length).toBeGreaterThan(0)
1779
+ // PPTX files are ZIP archives — first two bytes are PK (0x50, 0x4B)
1780
+ expect((result as Uint8Array)[0]).toBe(0x50)
1781
+ expect((result as Uint8Array)[1]).toBe(0x4b)
1782
+ }, 15000)
1783
+
1784
+ it('renders a document without explicit pages as a single slide', async () => {
1785
+ const doc = Document({
1786
+ title: 'Single Slide',
1787
+ children: [
1788
+ Heading({ children: 'Auto Slide' }),
1789
+ Text({ children: 'No explicit page wrapper.' }),
1790
+ ],
1791
+ })
1792
+
1793
+ const result = await render(doc, 'pptx')
1794
+ expect(result).toBeInstanceOf(Uint8Array)
1795
+ expect((result as Uint8Array).length).toBeGreaterThan(0)
1796
+ }, 15000)
1797
+
1798
+ it('renders all node types without errors', async () => {
1799
+ // 1x1 red pixel PNG as base64
1800
+ const redPixel =
1801
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='
1802
+
1803
+ const doc = Document({
1804
+ children: Page({
1805
+ children: [
1806
+ Heading({ children: 'Title' }),
1807
+ Text({ children: 'Body', bold: true, italic: true }),
1808
+ Image({ src: redPixel, width: 50, height: 50 }),
1809
+ Code({ children: 'const x = 1' }),
1810
+ Quote({ children: 'A quote' }),
1811
+ Link({ href: 'https://example.com', children: 'Link text' }),
1812
+ Button({ href: '/action', background: '#4f46e5', children: 'Click' }),
1813
+ Divider(),
1814
+ Spacer({ height: 20 }),
1815
+ List({ ordered: true, children: [ListItem({ children: 'one' })] }),
1816
+ Section({ children: Text({ children: 'nested' }) }),
1817
+ ],
1818
+ }),
1819
+ })
1820
+
1821
+ const result = await render(doc, 'pptx')
1822
+ expect(result).toBeInstanceOf(Uint8Array)
1823
+ expect((result as Uint8Array).length).toBeGreaterThan(0)
1824
+ }, 15000)
1825
+ })
1826
+
1827
+ // ─── Slack Renderer ─────────────────────────────────────────────────────────
1828
+
1829
+ describe('Slack renderer', () => {
1830
+ it('renders heading as header block', async () => {
1831
+ const doc = Document({ children: Heading({ children: 'Hello' }) })
1832
+ const json = (await render(doc, 'slack')) as string
1833
+ const parsed = JSON.parse(json)
1834
+ expect(parsed.blocks).toHaveLength(1)
1835
+ expect(parsed.blocks[0].type).toBe('header')
1836
+ })
1837
+
1838
+ it('renders text with bold as mrkdwn', async () => {
1839
+ const doc = Document({
1840
+ children: Text({ bold: true, children: 'Bold text' }),
1841
+ })
1842
+ const json = (await render(doc, 'slack')) as string
1843
+ const parsed = JSON.parse(json)
1844
+ expect(parsed.blocks[0].text.text).toContain('*Bold text*')
1845
+ })
1846
+
1847
+ it('renders button as actions block', async () => {
1848
+ const doc = Document({
1849
+ children: Button({ href: '/go', children: 'Click' }),
1850
+ })
1851
+ const json = (await render(doc, 'slack')) as string
1852
+ const parsed = JSON.parse(json)
1853
+ expect(parsed.blocks[0].type).toBe('actions')
1854
+ expect(parsed.blocks[0].elements[0].type).toBe('button')
1855
+ })
1856
+
1857
+ it('renders table as code block', async () => {
1858
+ const doc = Document({
1859
+ children: Table({ columns: ['A', 'B'], rows: [['1', '2']] }),
1860
+ })
1861
+ const json = (await render(doc, 'slack')) as string
1862
+ const parsed = JSON.parse(json)
1863
+ expect(parsed.blocks[0].text.text).toContain('*A*')
1864
+ expect(parsed.blocks[0].text.text).toContain('1 | 2')
1865
+ })
1866
+
1867
+ it('renders divider', async () => {
1868
+ const doc = Document({ children: Divider() })
1869
+ const json = (await render(doc, 'slack')) as string
1870
+ const parsed = JSON.parse(json)
1871
+ expect(parsed.blocks[0].type).toBe('divider')
1872
+ })
1873
+
1874
+ it('renders list as bullet points', async () => {
1875
+ const doc = Document({
1876
+ children: List({
1877
+ children: [
1878
+ ListItem({ children: 'one' }),
1879
+ ListItem({ children: 'two' }),
1880
+ ],
1881
+ }),
1882
+ })
1883
+ const json = (await render(doc, 'slack')) as string
1884
+ const parsed = JSON.parse(json)
1885
+ expect(parsed.blocks[0].text.text).toContain('• one')
1886
+ expect(parsed.blocks[0].text.text).toContain('• two')
1887
+ })
1888
+
1889
+ it('renders ordered list', async () => {
1890
+ const doc = Document({
1891
+ children: List({
1892
+ ordered: true,
1893
+ children: [ListItem({ children: 'a' }), ListItem({ children: 'b' })],
1894
+ }),
1895
+ })
1896
+ const json = (await render(doc, 'slack')) as string
1897
+ const parsed = JSON.parse(json)
1898
+ expect(parsed.blocks[0].text.text).toContain('1. a')
1899
+ expect(parsed.blocks[0].text.text).toContain('2. b')
1900
+ })
1901
+
1902
+ it('renders link in mrkdwn format', async () => {
1903
+ const doc = Document({
1904
+ children: Link({ href: 'https://x.com', children: 'X' }),
1905
+ })
1906
+ const json = (await render(doc, 'slack')) as string
1907
+ const parsed = JSON.parse(json)
1908
+ expect(parsed.blocks[0].text.text).toContain('<https://x.com|X>')
1909
+ })
1910
+
1911
+ it('renders code block', async () => {
1912
+ const doc = Document({
1913
+ children: Code({ language: 'js', children: 'const x = 1' }),
1914
+ })
1915
+ const json = (await render(doc, 'slack')) as string
1916
+ const parsed = JSON.parse(json)
1917
+ expect(parsed.blocks[0].text.text).toContain('```js')
1918
+ expect(parsed.blocks[0].text.text).toContain('const x = 1')
1919
+ })
1920
+
1921
+ it('renders quote with >', async () => {
1922
+ const doc = Document({ children: Quote({ children: 'wise' }) })
1923
+ const json = (await render(doc, 'slack')) as string
1924
+ const parsed = JSON.parse(json)
1925
+ expect(parsed.blocks[0].text.text).toContain('> wise')
1926
+ })
1927
+
1928
+ it('renders image with URL', async () => {
1929
+ const doc = Document({
1930
+ children: Image({ src: 'https://x.com/img.png', alt: 'Photo' }),
1931
+ })
1932
+ const json = (await render(doc, 'slack')) as string
1933
+ const parsed = JSON.parse(json)
1934
+ expect(parsed.blocks[0].type).toBe('image')
1935
+ expect(parsed.blocks[0].image_url).toBe('https://x.com/img.png')
1936
+ })
1937
+
1938
+ it('skips non-URL images', async () => {
1939
+ const doc = Document({
1940
+ children: Image({ src: 'data:image/png;base64,abc' }),
1941
+ })
1942
+ const json = (await render(doc, 'slack')) as string
1943
+ const parsed = JSON.parse(json)
1944
+ expect(parsed.blocks).toHaveLength(0)
1945
+ })
1946
+
1947
+ it('renders page-break as divider', async () => {
1948
+ const doc = Document({ children: PageBreak() })
1949
+ const json = (await render(doc, 'slack')) as string
1950
+ const parsed = JSON.parse(json)
1951
+ expect(parsed.blocks[0].type).toBe('divider')
1952
+ })
1953
+
1954
+ it('renders text with italic and strikethrough', async () => {
1955
+ const doc = Document({
1956
+ children: [
1957
+ Text({ italic: true, children: 'italic' }),
1958
+ Text({ strikethrough: true, children: 'struck' }),
1959
+ ],
1960
+ })
1961
+ const json = (await render(doc, 'slack')) as string
1962
+ const parsed = JSON.parse(json)
1963
+ expect(parsed.blocks[0].text.text).toContain('_italic_')
1964
+ expect(parsed.blocks[1].text.text).toContain('~struck~')
1965
+ })
1966
+
1967
+ it('renders image with caption', async () => {
1968
+ const doc = Document({
1969
+ children: Image({ src: 'https://x.com/img.png', caption: 'Nice' }),
1970
+ })
1971
+ const json = (await render(doc, 'slack')) as string
1972
+ const parsed = JSON.parse(json)
1973
+ expect(parsed.blocks[0].title.text).toBe('Nice')
1974
+ })
1975
+
1976
+ it('renders table with caption', async () => {
1977
+ const doc = Document({
1978
+ children: Table({ columns: ['X'], rows: [['1']], caption: 'My Table' }),
1979
+ })
1980
+ const json = (await render(doc, 'slack')) as string
1981
+ const parsed = JSON.parse(json)
1982
+ expect(parsed.blocks[0].text.text).toContain('_My Table_')
1983
+ })
1984
+ })
1985
+
1986
+ // ─── PageBreak ──────────────────────────────────────────────────────────────
1987
+
1988
+ describe('PageBreak', () => {
1989
+ it('creates a page-break node', () => {
1990
+ const pb = PageBreak()
1991
+ expect(pb.type).toBe('page-break')
1992
+ expect(pb.children).toEqual([])
1993
+ })
1994
+
1995
+ it('renders as CSS page-break in HTML', async () => {
1996
+ const doc = Document({ children: PageBreak() })
1997
+ const html = (await render(doc, 'html')) as string
1998
+ expect(html).toContain('page-break-after:always')
1999
+ })
2000
+
2001
+ it('renders as separator in email', async () => {
2002
+ const doc = Document({ children: PageBreak() })
2003
+ const html = (await render(doc, 'email')) as string
2004
+ expect(html).toContain('border-top:2px solid')
2005
+ })
2006
+
2007
+ it('renders as --- in markdown', async () => {
2008
+ const doc = Document({ children: PageBreak() })
2009
+ const md = (await render(doc, 'md')) as string
2010
+ expect(md).toContain('---')
2011
+ })
2012
+
2013
+ it('renders as separator in text', async () => {
2014
+ const doc = Document({ children: PageBreak() })
2015
+ const text = (await render(doc, 'text')) as string
2016
+ expect(text).toContain('═')
2017
+ })
2018
+
2019
+ it('builder pageBreak inserts page-break node', async () => {
2020
+ const doc = createDocument().heading('Page 1').pageBreak().heading('Page 2')
2021
+ const html = await doc.toHtml()
2022
+ expect(html).toContain('page-break-after:always')
2023
+ expect(html).toContain('Page 1')
2024
+ expect(html).toContain('Page 2')
2025
+ })
2026
+ })
2027
+
2028
+ // ─── RTL Support ────────────────────────────────────────────────────────────
2029
+
2030
+ describe('RTL support', () => {
2031
+ it('adds dir=rtl to HTML body', async () => {
2032
+ const doc = Document({ children: Text({ children: 'مرحبا' }) })
2033
+ const html = (await render(doc, 'html', { direction: 'rtl' })) as string
2034
+ expect(html).toContain('dir="rtl"')
2035
+ expect(html).toContain('direction:rtl')
2036
+ })
2037
+
2038
+ it('does not add dir for ltr (default)', async () => {
2039
+ const doc = Document({ children: Text({ children: 'Hello' }) })
2040
+ const html = (await render(doc, 'html')) as string
2041
+ expect(html).not.toContain('dir="rtl"')
2042
+ })
2043
+ })
2044
+
2045
+ // ─── keepTogether ───────────────────────────────────────────────────────────
2046
+
2047
+ describe('keepTogether', () => {
2048
+ it('table accepts keepTogether prop', () => {
2049
+ const t = Table({ columns: ['A'], rows: [['1']], keepTogether: true })
2050
+ expect(t.props.keepTogether).toBe(true)
2051
+ })
2052
+ })
2053
+
2054
+ // ─── Builder toSlack ────────────────────────────────────────────────────────
2055
+
2056
+ // ─── SVG Renderer ───────────────────────────────────────────────────────────
2057
+
2058
+ describe('SVG renderer', () => {
2059
+ it('renders a valid SVG document', async () => {
2060
+ const doc = Document({ children: Heading({ children: 'Title' }) })
2061
+ const svg = (await render(doc, 'svg')) as string
2062
+ expect(svg).toContain('<svg xmlns="http://www.w3.org/2000/svg"')
2063
+ expect(svg).toContain('</svg>')
2064
+ expect(svg).toContain('Title')
2065
+ })
2066
+
2067
+ it('renders heading with correct font size', async () => {
2068
+ const doc = Document({ children: Heading({ level: 2, children: 'Sub' }) })
2069
+ const svg = (await render(doc, 'svg')) as string
2070
+ expect(svg).toContain('font-size="24"')
2071
+ expect(svg).toContain('font-weight="bold"')
2072
+ })
2073
+
2074
+ it('renders text with bold and italic', async () => {
2075
+ const doc = Document({
2076
+ children: [
2077
+ Text({ bold: true, children: 'Bold' }),
2078
+ Text({ italic: true, children: 'Italic' }),
2079
+ ],
2080
+ })
2081
+ const svg = (await render(doc, 'svg')) as string
2082
+ expect(svg).toContain('font-weight="bold"')
2083
+ expect(svg).toContain('font-style="italic"')
2084
+ })
2085
+
2086
+ it('renders table with header and rows', async () => {
2087
+ const doc = Document({
2088
+ children: Table({
2089
+ columns: ['Name', 'Price'],
2090
+ rows: [['Widget', '$10']],
2091
+ headerStyle: { background: '#000', color: '#fff' },
2092
+ striped: true,
2093
+ }),
2094
+ })
2095
+ const svg = (await render(doc, 'svg')) as string
2096
+ expect(svg).toContain('Name')
2097
+ expect(svg).toContain('Widget')
2098
+ expect(svg).toContain('fill="#000"')
2099
+ })
2100
+
2101
+ it('renders image from data URL', async () => {
2102
+ const doc = Document({
2103
+ children: Image({
2104
+ src: 'data:image/png;base64,abc',
2105
+ width: 200,
2106
+ height: 100,
2107
+ caption: 'Photo',
2108
+ }),
2109
+ })
2110
+ const svg = (await render(doc, 'svg')) as string
2111
+ expect(svg).toContain('<image')
2112
+ expect(svg).toContain('data:image/png;base64,abc')
2113
+ expect(svg).toContain('Photo')
2114
+ })
2115
+
2116
+ it('renders image placeholder for local paths', async () => {
2117
+ const doc = Document({
2118
+ children: Image({ src: '/local.png', alt: 'Local' }),
2119
+ })
2120
+ const svg = (await render(doc, 'svg')) as string
2121
+ expect(svg).toContain('Local')
2122
+ expect(svg).toContain('fill="#f0f0f0"')
2123
+ })
2124
+
2125
+ it('renders list', async () => {
2126
+ const doc = Document({
2127
+ children: List({
2128
+ ordered: true,
2129
+ children: [
2130
+ ListItem({ children: 'one' }),
2131
+ ListItem({ children: 'two' }),
2132
+ ],
2133
+ }),
2134
+ })
2135
+ const svg = (await render(doc, 'svg')) as string
2136
+ expect(svg).toContain('1. one')
2137
+ expect(svg).toContain('2. two')
2138
+ })
2139
+
2140
+ it('renders unordered list', async () => {
2141
+ const doc = Document({
2142
+ children: List({
2143
+ children: [ListItem({ children: 'a' }), ListItem({ children: 'b' })],
2144
+ }),
2145
+ })
2146
+ const svg = (await render(doc, 'svg')) as string
2147
+ expect(svg).toContain('• a')
2148
+ expect(svg).toContain('• b')
2149
+ })
2150
+
2151
+ it('renders code block', async () => {
2152
+ const doc = Document({ children: Code({ children: 'const x = 1' }) })
2153
+ const svg = (await render(doc, 'svg')) as string
2154
+ expect(svg).toContain('const x = 1')
2155
+ expect(svg).toContain('font-family="monospace"')
2156
+ expect(svg).toContain('fill="#f5f5f5"') // background
2157
+ })
2158
+
2159
+ it('renders divider', async () => {
2160
+ const doc = Document({ children: Divider({ color: '#ccc', thickness: 2 }) })
2161
+ const svg = (await render(doc, 'svg')) as string
2162
+ expect(svg).toContain('stroke="#ccc"')
2163
+ expect(svg).toContain('stroke-width="2"')
2164
+ })
2165
+
2166
+ it('renders button', async () => {
2167
+ const doc = Document({
2168
+ children: Button({
2169
+ href: '/pay',
2170
+ background: '#4f46e5',
2171
+ children: 'Pay',
2172
+ }),
2173
+ })
2174
+ const svg = (await render(doc, 'svg')) as string
2175
+ expect(svg).toContain('fill="#4f46e5"')
2176
+ expect(svg).toContain('Pay')
2177
+ })
2178
+
2179
+ it('renders quote', async () => {
2180
+ const doc = Document({ children: Quote({ children: 'wise words' }) })
2181
+ const svg = (await render(doc, 'svg')) as string
2182
+ expect(svg).toContain('wise words')
2183
+ expect(svg).toContain('font-style="italic"')
2184
+ })
2185
+
2186
+ it('renders link', async () => {
2187
+ const doc = Document({
2188
+ children: Link({ href: 'https://x.com', children: 'X' }),
2189
+ })
2190
+ const svg = (await render(doc, 'svg')) as string
2191
+ expect(svg).toContain('<a href="https://x.com">')
2192
+ expect(svg).toContain('X')
2193
+ })
2194
+
2195
+ it('renders spacer', async () => {
2196
+ const doc = Document({
2197
+ children: [
2198
+ Text({ children: 'A' }),
2199
+ Spacer({ height: 50 }),
2200
+ Text({ children: 'B' }),
2201
+ ],
2202
+ })
2203
+ const svg = (await render(doc, 'svg')) as string
2204
+ expect(svg).toContain('A')
2205
+ expect(svg).toContain('B')
2206
+ })
2207
+
2208
+ it('renders page-break as dashed line', async () => {
2209
+ const doc = Document({ children: PageBreak() })
2210
+ const svg = (await render(doc, 'svg')) as string
2211
+ expect(svg).toContain('stroke-dasharray')
2212
+ })
2213
+
2214
+ it('supports RTL direction', async () => {
2215
+ const doc = Document({ children: Text({ children: 'مرحبا' }) })
2216
+ const svg = (await render(doc, 'svg', { direction: 'rtl' })) as string
2217
+ expect(svg).toContain('direction="rtl"')
2218
+ })
2219
+
2220
+ it('auto-calculates height from content', async () => {
2221
+ const doc = Document({
2222
+ children: [
2223
+ Heading({ children: 'A' }),
2224
+ Text({ children: 'B' }),
2225
+ Text({ children: 'C' }),
2226
+ ],
2227
+ })
2228
+ const svg = (await render(doc, 'svg')) as string
2229
+ const match = svg.match(/height="(\d+)"/)
2230
+ expect(match).toBeTruthy()
2231
+ expect(Number(match![1])).toBeGreaterThan(80)
2232
+ })
2233
+
2234
+ it('renders image from HTTP URL', async () => {
2235
+ const doc = Document({
2236
+ children: Image({ src: 'https://x.com/img.png', width: 300 }),
2237
+ })
2238
+ const svg = (await render(doc, 'svg')) as string
2239
+ expect(svg).toContain('<image')
2240
+ expect(svg).toContain('https://x.com/img.png')
2241
+ })
2242
+
2243
+ it('builder toSvg works', async () => {
2244
+ const svg = await createDocument().heading('Hi').text('World').toSvg()
2245
+ expect(svg).toContain('<svg')
2246
+ expect(svg).toContain('Hi')
2247
+ expect(svg).toContain('World')
2248
+ })
2249
+ })
2250
+
2251
+ // ─── Teams Renderer ─────────────────────────────────────────────────────────
2252
+
2253
+ describe('Teams renderer', () => {
2254
+ it('renders heading as TextBlock', async () => {
2255
+ const doc = Document({ children: Heading({ children: 'Hello' }) })
2256
+ const json = (await render(doc, 'teams')) as string
2257
+ const card = JSON.parse(json)
2258
+ expect(card.type).toBe('AdaptiveCard')
2259
+ expect(card.body[0].type).toBe('TextBlock')
2260
+ expect(card.body[0].text).toBe('Hello')
2261
+ expect(card.body[0].weight).toBe('bolder')
2262
+ })
2263
+
2264
+ it('renders bold text', async () => {
2265
+ const doc = Document({ children: Text({ bold: true, children: 'Bold' }) })
2266
+ const json = (await render(doc, 'teams')) as string
2267
+ const card = JSON.parse(json)
2268
+ expect(card.body[0].text).toContain('**Bold**')
2269
+ })
2270
+
2271
+ it('renders button as Action.OpenUrl', async () => {
2272
+ const doc = Document({
2273
+ children: Button({ href: '/go', children: 'Click' }),
2274
+ })
2275
+ const json = (await render(doc, 'teams')) as string
2276
+ const card = JSON.parse(json)
2277
+ expect(card.body[0].type).toBe('ActionSet')
2278
+ expect(card.body[0].actions[0].type).toBe('Action.OpenUrl')
2279
+ })
2280
+
2281
+ it('renders table as ColumnSet', async () => {
2282
+ const doc = Document({
2283
+ children: Table({ columns: ['A', 'B'], rows: [['1', '2']] }),
2284
+ })
2285
+ const json = (await render(doc, 'teams')) as string
2286
+ const card = JSON.parse(json)
2287
+ expect(card.body[0].type).toBe('ColumnSet')
2288
+ })
2289
+
2290
+ it('renders list', async () => {
2291
+ const doc = Document({
2292
+ children: List({
2293
+ children: [
2294
+ ListItem({ children: 'one' }),
2295
+ ListItem({ children: 'two' }),
2296
+ ],
2297
+ }),
2298
+ })
2299
+ const json = (await render(doc, 'teams')) as string
2300
+ const card = JSON.parse(json)
2301
+ expect(card.body[0].text).toContain('• one')
2302
+ })
2303
+
2304
+ it('renders code as monospace', async () => {
2305
+ const doc = Document({ children: Code({ children: 'x = 1' }) })
2306
+ const json = (await render(doc, 'teams')) as string
2307
+ const card = JSON.parse(json)
2308
+ expect(card.body[0].fontType).toBe('monospace')
2309
+ })
2310
+
2311
+ it('renders divider as separator', async () => {
2312
+ const doc = Document({ children: Divider() })
2313
+ const json = (await render(doc, 'teams')) as string
2314
+ const card = JSON.parse(json)
2315
+ expect(card.body[0].separator).toBe(true)
2316
+ })
2317
+
2318
+ it('renders image with URL', async () => {
2319
+ const doc = Document({
2320
+ children: Image({ src: 'https://x.com/img.png', alt: 'Photo' }),
2321
+ })
2322
+ const json = (await render(doc, 'teams')) as string
2323
+ const card = JSON.parse(json)
2324
+ expect(card.body[0].type).toBe('Image')
2325
+ })
2326
+
2327
+ it('renders quote as Container', async () => {
2328
+ const doc = Document({ children: Quote({ children: 'wise' }) })
2329
+ const json = (await render(doc, 'teams')) as string
2330
+ const card = JSON.parse(json)
2331
+ expect(card.body[0].type).toBe('Container')
2332
+ expect(card.body[0].style).toBe('emphasis')
2333
+ })
2334
+
2335
+ it('renders link as markdown', async () => {
2336
+ const doc = Document({
2337
+ children: Link({ href: 'https://x.com', children: 'X' }),
2338
+ })
2339
+ const json = (await render(doc, 'teams')) as string
2340
+ const card = JSON.parse(json)
2341
+ expect(card.body[0].text).toContain('[X](https://x.com)')
2342
+ })
2343
+
2344
+ it('builder toTeams works', async () => {
2345
+ const json = await createDocument().heading('Hi').toTeams()
2346
+ const card = JSON.parse(json)
2347
+ expect(card.type).toBe('AdaptiveCard')
2348
+ })
2349
+ })
2350
+
2351
+ // ─── Discord Renderer ───────────────────────────────────────────────────────
2352
+
2353
+ describe('Discord renderer', () => {
2354
+ it('renders heading as embed title', async () => {
2355
+ const doc = Document({ children: Heading({ children: 'Title' }) })
2356
+ const json = (await render(doc, 'discord')) as string
2357
+ const payload = JSON.parse(json)
2358
+ expect(payload.embeds[0].title).toBe('Title')
2359
+ })
2360
+
2361
+ it('renders text in description', async () => {
2362
+ const doc = Document({
2363
+ children: [Heading({ children: 'T' }), Text({ children: 'Body' })],
2364
+ })
2365
+ const json = (await render(doc, 'discord')) as string
2366
+ const payload = JSON.parse(json)
2367
+ expect(payload.embeds[0].description).toContain('Body')
2368
+ })
2369
+
2370
+ it('renders small table as fields', async () => {
2371
+ const doc = Document({
2372
+ children: Table({
2373
+ columns: ['A', 'B'],
2374
+ rows: [
2375
+ ['1', '2'],
2376
+ ['3', '4'],
2377
+ ],
2378
+ }),
2379
+ })
2380
+ const json = (await render(doc, 'discord')) as string
2381
+ const payload = JSON.parse(json)
2382
+ expect(payload.embeds[0].fields).toHaveLength(2)
2383
+ expect(payload.embeds[0].fields[0].name).toBe('A')
2384
+ expect(payload.embeds[0].fields[0].inline).toBe(true)
2385
+ })
2386
+
2387
+ it('renders image as embed image', async () => {
2388
+ const doc = Document({ children: Image({ src: 'https://x.com/img.png' }) })
2389
+ const json = (await render(doc, 'discord')) as string
2390
+ const payload = JSON.parse(json)
2391
+ expect(payload.embeds[0].image.url).toBe('https://x.com/img.png')
2392
+ })
2393
+
2394
+ it('renders quote with >', async () => {
2395
+ const doc = Document({ children: Quote({ children: 'wise' }) })
2396
+ const json = (await render(doc, 'discord')) as string
2397
+ const payload = JSON.parse(json)
2398
+ expect(payload.embeds[0].description).toContain('> wise')
2399
+ })
2400
+
2401
+ it('renders code block', async () => {
2402
+ const doc = Document({
2403
+ children: Code({ language: 'js', children: 'x()' }),
2404
+ })
2405
+ const json = (await render(doc, 'discord')) as string
2406
+ const payload = JSON.parse(json)
2407
+ expect(payload.embeds[0].description).toContain('```js')
2408
+ })
2409
+
2410
+ it('renders list', async () => {
2411
+ const doc = Document({
2412
+ children: List({
2413
+ ordered: true,
2414
+ children: [ListItem({ children: 'a' })],
2415
+ }),
2416
+ })
2417
+ const json = (await render(doc, 'discord')) as string
2418
+ const payload = JSON.parse(json)
2419
+ expect(payload.embeds[0].description).toContain('1. a')
2420
+ })
2421
+
2422
+ it('builder toDiscord works', async () => {
2423
+ const json = await createDocument().heading('Hi').text('World').toDiscord()
2424
+ const payload = JSON.parse(json)
2425
+ expect(payload.embeds).toHaveLength(1)
2426
+ })
2427
+ })
2428
+
2429
+ // ─── Telegram Renderer ──────────────────────────────────────────────────────
2430
+
2431
+ describe('Telegram renderer', () => {
2432
+ it('renders heading as bold', async () => {
2433
+ const doc = Document({ children: Heading({ children: 'Title' }) })
2434
+ const html = (await render(doc, 'telegram')) as string
2435
+ expect(html).toContain('<b>Title</b>')
2436
+ })
2437
+
2438
+ it('renders text with formatting', async () => {
2439
+ const doc = Document({
2440
+ children: [
2441
+ Text({ bold: true, children: 'Bold' }),
2442
+ Text({ italic: true, children: 'Italic' }),
2443
+ Text({ underline: true, children: 'Under' }),
2444
+ Text({ strikethrough: true, children: 'Struck' }),
2445
+ ],
2446
+ })
2447
+ const html = (await render(doc, 'telegram')) as string
2448
+ expect(html).toContain('<b>Bold</b>')
2449
+ expect(html).toContain('<i>Italic</i>')
2450
+ expect(html).toContain('<u>Under</u>')
2451
+ expect(html).toContain('<s>Struck</s>')
2452
+ })
2453
+
2454
+ it('renders link as <a>', async () => {
2455
+ const doc = Document({
2456
+ children: Link({ href: 'https://x.com', children: 'X' }),
2457
+ })
2458
+ const html = (await render(doc, 'telegram')) as string
2459
+ expect(html).toContain('<a href="https://x.com">X</a>')
2460
+ })
2461
+
2462
+ it('renders table as pre-formatted text', async () => {
2463
+ const doc = Document({
2464
+ children: Table({ columns: ['A', 'B'], rows: [['1', '2']] }),
2465
+ })
2466
+ const html = (await render(doc, 'telegram')) as string
2467
+ expect(html).toContain('<pre>')
2468
+ expect(html).toContain('A | B')
2469
+ expect(html).toContain('1 | 2')
2470
+ })
2471
+
2472
+ it('renders code with language', async () => {
2473
+ const doc = Document({
2474
+ children: Code({ language: 'python', children: 'x = 1' }),
2475
+ })
2476
+ const html = (await render(doc, 'telegram')) as string
2477
+ expect(html).toContain('language-python')
2478
+ expect(html).toContain('x = 1')
2479
+ })
2480
+
2481
+ it('renders code without language', async () => {
2482
+ const doc = Document({ children: Code({ children: 'x = 1' }) })
2483
+ const html = (await render(doc, 'telegram')) as string
2484
+ expect(html).toContain('<pre>x = 1</pre>')
2485
+ })
2486
+
2487
+ it('renders quote as blockquote', async () => {
2488
+ const doc = Document({ children: Quote({ children: 'wise' }) })
2489
+ const html = (await render(doc, 'telegram')) as string
2490
+ expect(html).toContain('<blockquote>wise</blockquote>')
2491
+ })
2492
+
2493
+ it('renders list', async () => {
2494
+ const doc = Document({
2495
+ children: List({
2496
+ children: [
2497
+ ListItem({ children: 'one' }),
2498
+ ListItem({ children: 'two' }),
2499
+ ],
2500
+ }),
2501
+ })
2502
+ const html = (await render(doc, 'telegram')) as string
2503
+ expect(html).toContain('• one')
2504
+ expect(html).toContain('• two')
2505
+ })
2506
+
2507
+ it('renders button as link', async () => {
2508
+ const doc = Document({
2509
+ children: Button({ href: '/pay', children: 'Pay' }),
2510
+ })
2511
+ const html = (await render(doc, 'telegram')) as string
2512
+ expect(html).toContain('<a href="/pay">Pay</a>')
2513
+ })
2514
+
2515
+ it('skips images (sent separately in Telegram)', async () => {
2516
+ const doc = Document({ children: Image({ src: 'https://x.com/img.png' }) })
2517
+ const html = (await render(doc, 'telegram')) as string
2518
+ expect(html).toBe('')
2519
+ })
2520
+
2521
+ it('escapes HTML entities', async () => {
2522
+ const doc = Document({
2523
+ children: Text({ children: '<script>alert(1)</script>' }),
2524
+ })
2525
+ const html = (await render(doc, 'telegram')) as string
2526
+ expect(html).not.toContain('<script>')
2527
+ expect(html).toContain('&lt;script&gt;')
2528
+ })
2529
+
2530
+ it('builder toTelegram works', async () => {
2531
+ const html = await createDocument().heading('Hi').text('World').toTelegram()
2532
+ expect(html).toContain('<b>Hi</b>')
2533
+ expect(html).toContain('World')
2534
+ })
2535
+ })
2536
+
2537
+ // ─── Notion Renderer ────────────────────────────────────────────────────────
2538
+
2539
+ describe('Notion renderer', () => {
2540
+ it('renders heading as heading block', async () => {
2541
+ const doc = Document({ children: Heading({ children: 'Title' }) })
2542
+ const json = (await render(doc, 'notion')) as string
2543
+ const parsed = JSON.parse(json)
2544
+ expect(parsed.children[0].type).toBe('heading_1')
2545
+ })
2546
+
2547
+ it('renders h2 as heading_2', async () => {
2548
+ const doc = Document({ children: Heading({ level: 2, children: 'Sub' }) })
2549
+ const json = (await render(doc, 'notion')) as string
2550
+ const parsed = JSON.parse(json)
2551
+ expect(parsed.children[0].type).toBe('heading_2')
2552
+ })
2553
+
2554
+ it('renders h3+ as heading_3', async () => {
2555
+ const doc = Document({ children: Heading({ level: 4, children: 'Sub' }) })
2556
+ const json = (await render(doc, 'notion')) as string
2557
+ const parsed = JSON.parse(json)
2558
+ expect(parsed.children[0].type).toBe('heading_3')
2559
+ })
2560
+
2561
+ it('renders text as paragraph', async () => {
2562
+ const doc = Document({ children: Text({ bold: true, children: 'Bold' }) })
2563
+ const json = (await render(doc, 'notion')) as string
2564
+ const parsed = JSON.parse(json)
2565
+ expect(parsed.children[0].type).toBe('paragraph')
2566
+ expect(parsed.children[0].paragraph.rich_text[0].annotations.bold).toBe(
2567
+ true,
2568
+ )
2569
+ })
2570
+
2571
+ it('renders table with header row', async () => {
2572
+ const doc = Document({
2573
+ children: Table({ columns: ['A', 'B'], rows: [['1', '2']] }),
2574
+ })
2575
+ const json = (await render(doc, 'notion')) as string
2576
+ const parsed = JSON.parse(json)
2577
+ expect(parsed.children[0].type).toBe('table')
2578
+ expect(parsed.children[0].table.has_column_header).toBe(true)
2579
+ })
2580
+
2581
+ it('renders bulleted list', async () => {
2582
+ const doc = Document({
2583
+ children: List({ children: [ListItem({ children: 'a' })] }),
2584
+ })
2585
+ const json = (await render(doc, 'notion')) as string
2586
+ const parsed = JSON.parse(json)
2587
+ expect(parsed.children[0].type).toBe('bulleted_list_item')
2588
+ })
2589
+
2590
+ it('renders numbered list', async () => {
2591
+ const doc = Document({
2592
+ children: List({
2593
+ ordered: true,
2594
+ children: [ListItem({ children: 'a' })],
2595
+ }),
2596
+ })
2597
+ const json = (await render(doc, 'notion')) as string
2598
+ const parsed = JSON.parse(json)
2599
+ expect(parsed.children[0].type).toBe('numbered_list_item')
2600
+ })
2601
+
2602
+ it('renders code block', async () => {
2603
+ const doc = Document({
2604
+ children: Code({ language: 'python', children: 'x = 1' }),
2605
+ })
2606
+ const json = (await render(doc, 'notion')) as string
2607
+ const parsed = JSON.parse(json)
2608
+ expect(parsed.children[0].type).toBe('code')
2609
+ expect(parsed.children[0].code.language).toBe('python')
2610
+ })
2611
+
2612
+ it('renders quote', async () => {
2613
+ const doc = Document({ children: Quote({ children: 'wise' }) })
2614
+ const json = (await render(doc, 'notion')) as string
2615
+ const parsed = JSON.parse(json)
2616
+ expect(parsed.children[0].type).toBe('quote')
2617
+ })
2618
+
2619
+ it('renders divider', async () => {
2620
+ const doc = Document({ children: Divider() })
2621
+ const json = (await render(doc, 'notion')) as string
2622
+ const parsed = JSON.parse(json)
2623
+ expect(parsed.children[0].type).toBe('divider')
2624
+ })
2625
+
2626
+ it('renders image with URL', async () => {
2627
+ const doc = Document({ children: Image({ src: 'https://x.com/img.png' }) })
2628
+ const json = (await render(doc, 'notion')) as string
2629
+ const parsed = JSON.parse(json)
2630
+ expect(parsed.children[0].type).toBe('image')
2631
+ })
2632
+
2633
+ it('renders link as paragraph with link', async () => {
2634
+ const doc = Document({
2635
+ children: Link({ href: 'https://x.com', children: 'X' }),
2636
+ })
2637
+ const json = (await render(doc, 'notion')) as string
2638
+ const parsed = JSON.parse(json)
2639
+ expect(parsed.children[0].paragraph.rich_text[0].text.link.url).toBe(
2640
+ 'https://x.com',
2641
+ )
2642
+ })
2643
+
2644
+ it('builder toNotion works', async () => {
2645
+ const json = await createDocument().heading('Hi').toNotion()
2646
+ const parsed = JSON.parse(json)
2647
+ expect(parsed.children.length).toBeGreaterThan(0)
2648
+ })
2649
+ })
2650
+
2651
+ // ─── Confluence/Jira Renderer ───────────────────────────────────────────────
2652
+
2653
+ describe('Confluence renderer', () => {
2654
+ it('renders ADF document', async () => {
2655
+ const doc = Document({ children: Heading({ children: 'Title' }) })
2656
+ const json = (await render(doc, 'confluence')) as string
2657
+ const adf = JSON.parse(json)
2658
+ expect(adf.version).toBe(1)
2659
+ expect(adf.type).toBe('doc')
2660
+ expect(adf.content[0].type).toBe('heading')
2661
+ })
2662
+
2663
+ it('renders text with marks', async () => {
2664
+ const doc = Document({
2665
+ children: Text({ bold: true, italic: true, children: 'styled' }),
2666
+ })
2667
+ const json = (await render(doc, 'confluence')) as string
2668
+ const adf = JSON.parse(json)
2669
+ const marks = adf.content[0].content[0].marks
2670
+ expect(marks.some((m: any) => m.type === 'strong')).toBe(true)
2671
+ expect(marks.some((m: any) => m.type === 'em')).toBe(true)
2672
+ })
2673
+
2674
+ it('renders table', async () => {
2675
+ const doc = Document({
2676
+ children: Table({ columns: ['A'], rows: [['1']] }),
2677
+ })
2678
+ const json = (await render(doc, 'confluence')) as string
2679
+ const adf = JSON.parse(json)
2680
+ expect(adf.content[0].type).toBe('table')
2681
+ expect(adf.content[0].content[0].content[0].type).toBe('tableHeader')
2682
+ })
2683
+
2684
+ it('renders ordered list', async () => {
2685
+ const doc = Document({
2686
+ children: List({
2687
+ ordered: true,
2688
+ children: [ListItem({ children: 'a' })],
2689
+ }),
2690
+ })
2691
+ const json = (await render(doc, 'confluence')) as string
2692
+ const adf = JSON.parse(json)
2693
+ expect(adf.content[0].type).toBe('orderedList')
2694
+ })
2695
+
2696
+ it('renders code block', async () => {
2697
+ const doc = Document({
2698
+ children: Code({ language: 'java', children: 'int x = 1;' }),
2699
+ })
2700
+ const json = (await render(doc, 'confluence')) as string
2701
+ const adf = JSON.parse(json)
2702
+ expect(adf.content[0].type).toBe('codeBlock')
2703
+ expect(adf.content[0].attrs.language).toBe('java')
2704
+ })
2705
+
2706
+ it('renders blockquote', async () => {
2707
+ const doc = Document({ children: Quote({ children: 'wise' }) })
2708
+ const json = (await render(doc, 'confluence')) as string
2709
+ const adf = JSON.parse(json)
2710
+ expect(adf.content[0].type).toBe('blockquote')
2711
+ })
2712
+
2713
+ it('renders rule (divider)', async () => {
2714
+ const doc = Document({ children: Divider() })
2715
+ const json = (await render(doc, 'confluence')) as string
2716
+ const adf = JSON.parse(json)
2717
+ expect(adf.content[0].type).toBe('rule')
2718
+ })
2719
+
2720
+ it('renders link with href', async () => {
2721
+ const doc = Document({
2722
+ children: Link({ href: 'https://x.com', children: 'X' }),
2723
+ })
2724
+ const json = (await render(doc, 'confluence')) as string
2725
+ const adf = JSON.parse(json)
2726
+ expect(adf.content[0].content[0].marks[0].attrs.href).toBe('https://x.com')
2727
+ })
2728
+
2729
+ it('builder toConfluence works', async () => {
2730
+ const json = await createDocument().heading('Hi').toConfluence()
2731
+ const adf = JSON.parse(json)
2732
+ expect(adf.type).toBe('doc')
2733
+ })
2734
+ })
2735
+
2736
+ // ─── WhatsApp Renderer ──────────────────────────────────────────────────────
2737
+
2738
+ describe('WhatsApp renderer', () => {
2739
+ it('renders heading as bold', async () => {
2740
+ const doc = Document({ children: Heading({ children: 'Title' }) })
2741
+ const text = (await render(doc, 'whatsapp')) as string
2742
+ expect(text).toContain('*Title*')
2743
+ })
2744
+
2745
+ it('renders bold, italic, strikethrough', async () => {
2746
+ const doc = Document({
2747
+ children: [
2748
+ Text({ bold: true, children: 'Bold' }),
2749
+ Text({ italic: true, children: 'Italic' }),
2750
+ Text({ strikethrough: true, children: 'Struck' }),
2751
+ ],
2752
+ })
2753
+ const text = (await render(doc, 'whatsapp')) as string
2754
+ expect(text).toContain('*Bold*')
2755
+ expect(text).toContain('_Italic_')
2756
+ expect(text).toContain('~Struck~')
2757
+ })
2758
+
2759
+ it('renders code as triple backticks', async () => {
2760
+ const doc = Document({ children: Code({ children: 'x = 1' }) })
2761
+ const text = (await render(doc, 'whatsapp')) as string
2762
+ expect(text).toContain('```x = 1```')
2763
+ })
2764
+
2765
+ it('renders quote with >', async () => {
2766
+ const doc = Document({ children: Quote({ children: 'wise' }) })
2767
+ const text = (await render(doc, 'whatsapp')) as string
2768
+ expect(text).toContain('> wise')
2769
+ })
2770
+
2771
+ it('renders link as text + URL', async () => {
2772
+ const doc = Document({
2773
+ children: Link({ href: 'https://x.com', children: 'X' }),
2774
+ })
2775
+ const text = (await render(doc, 'whatsapp')) as string
2776
+ expect(text).toContain('X: https://x.com')
2777
+ })
2778
+
2779
+ it('renders table', async () => {
2780
+ const doc = Document({
2781
+ children: Table({ columns: ['A', 'B'], rows: [['1', '2']] }),
2782
+ })
2783
+ const text = (await render(doc, 'whatsapp')) as string
2784
+ expect(text).toContain('*A* | *B*')
2785
+ expect(text).toContain('1 | 2')
2786
+ })
2787
+
2788
+ it('renders list', async () => {
2789
+ const doc = Document({
2790
+ children: List({ children: [ListItem({ children: 'one' })] }),
2791
+ })
2792
+ const text = (await render(doc, 'whatsapp')) as string
2793
+ expect(text).toContain('• one')
2794
+ })
2795
+
2796
+ it('skips images', async () => {
2797
+ const doc = Document({ children: Image({ src: 'https://x.com/img.png' }) })
2798
+ const text = (await render(doc, 'whatsapp')) as string
2799
+ expect(text).toBe('')
2800
+ })
2801
+
2802
+ it('builder toWhatsApp works', async () => {
2803
+ const text = await createDocument().heading('Hi').text('World').toWhatsApp()
2804
+ expect(text).toContain('*Hi*')
2805
+ expect(text).toContain('World')
2806
+ })
2807
+ })
2808
+
2809
+ // ─── Google Chat Renderer ───────────────────────────────────────────────────
2810
+
2811
+ describe('Google Chat renderer', () => {
2812
+ it('renders card with header', async () => {
2813
+ const doc = Document({
2814
+ title: 'Report',
2815
+ children: Text({ children: 'Body' }),
2816
+ })
2817
+ const json = (await render(doc, 'google-chat')) as string
2818
+ const card = JSON.parse(json)
2819
+ expect(card.cardsV2[0].card.header.title).toBe('Report')
2820
+ })
2821
+
2822
+ it('renders heading as decorated text', async () => {
2823
+ const doc = Document({ children: Heading({ children: 'Title' }) })
2824
+ const json = (await render(doc, 'google-chat')) as string
2825
+ const card = JSON.parse(json)
2826
+ expect(
2827
+ card.cardsV2[0].card.sections[0].widgets[0].decoratedText.text,
2828
+ ).toContain('<b>Title</b>')
2829
+ })
2830
+
2831
+ it('renders text paragraph', async () => {
2832
+ const doc = Document({ children: Text({ bold: true, children: 'Bold' }) })
2833
+ const json = (await render(doc, 'google-chat')) as string
2834
+ const card = JSON.parse(json)
2835
+ expect(
2836
+ card.cardsV2[0].card.sections[0].widgets[0].textParagraph.text,
2837
+ ).toContain('<b>Bold</b>')
2838
+ })
2839
+
2840
+ it('renders button', async () => {
2841
+ const doc = Document({
2842
+ children: Button({ href: '/go', children: 'Click' }),
2843
+ })
2844
+ const json = (await render(doc, 'google-chat')) as string
2845
+ const card = JSON.parse(json)
2846
+ expect(
2847
+ card.cardsV2[0].card.sections[0].widgets[0].buttonList.buttons[0].text,
2848
+ ).toBe('Click')
2849
+ })
2850
+
2851
+ it('renders divider', async () => {
2852
+ const doc = Document({ children: Divider() })
2853
+ const json = (await render(doc, 'google-chat')) as string
2854
+ const card = JSON.parse(json)
2855
+ expect(card.cardsV2[0].card.sections[0].widgets[0].divider).toBeDefined()
2856
+ })
2857
+
2858
+ it('renders image', async () => {
2859
+ const doc = Document({
2860
+ children: Image({ src: 'https://x.com/img.png', alt: 'Photo' }),
2861
+ })
2862
+ const json = (await render(doc, 'google-chat')) as string
2863
+ const card = JSON.parse(json)
2864
+ expect(card.cardsV2[0].card.sections[0].widgets[0].image.imageUrl).toBe(
2865
+ 'https://x.com/img.png',
2866
+ )
2867
+ })
2868
+
2869
+ it('renders link', async () => {
2870
+ const doc = Document({
2871
+ children: Link({ href: 'https://x.com', children: 'X' }),
2872
+ })
2873
+ const json = (await render(doc, 'google-chat')) as string
2874
+ const card = JSON.parse(json)
2875
+ expect(
2876
+ card.cardsV2[0].card.sections[0].widgets[0].textParagraph.text,
2877
+ ).toContain('href="https://x.com"')
2878
+ })
2879
+
2880
+ it('renders list', async () => {
2881
+ const doc = Document({
2882
+ children: List({ children: [ListItem({ children: 'one' })] }),
2883
+ })
2884
+ const json = (await render(doc, 'google-chat')) as string
2885
+ const card = JSON.parse(json)
2886
+ expect(
2887
+ card.cardsV2[0].card.sections[0].widgets[0].textParagraph.text,
2888
+ ).toContain('• one')
2889
+ })
2890
+
2891
+ it('uses first heading as title when no title prop', async () => {
2892
+ const doc = Document({
2893
+ children: [
2894
+ Heading({ children: 'Auto Title' }),
2895
+ Text({ children: 'body' }),
2896
+ ],
2897
+ })
2898
+ const json = (await render(doc, 'google-chat')) as string
2899
+ const card = JSON.parse(json)
2900
+ expect(card.cardsV2[0].card.header.title).toBe('Auto Title')
2901
+ })
2902
+
2903
+ it('builder toGoogleChat works', async () => {
2904
+ const json = await createDocument({ title: 'Hi' })
2905
+ .text('World')
2906
+ .toGoogleChat()
2907
+ const card = JSON.parse(json)
2908
+ expect(card.cardsV2[0].card.header.title).toBe('Hi')
2909
+ })
2910
+ })
2911
+
2912
+ describe('builder toSlack', () => {
2913
+ it('renders to Slack JSON', async () => {
2914
+ const result = await createDocument().heading('Hi').text('World').toSlack()
2915
+ const parsed = JSON.parse(result)
2916
+ expect(parsed.blocks).toHaveLength(2)
2917
+ expect(parsed.blocks[0].type).toBe('header')
2918
+ expect(parsed.blocks[1].type).toBe('section')
2919
+ })
2920
+ })