@pyreon/document 0.12.7 → 0.12.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/document",
3
- "version": "0.12.7",
3
+ "version": "0.12.9",
4
4
  "description": "Universal document rendering for Pyreon — one template, every output format (HTML, PDF, DOCX, email, XLSX, Markdown, and more)",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/document#readme",
6
6
  "bugs": {
@@ -42,8 +42,8 @@
42
42
  },
43
43
  "devDependencies": {
44
44
  "@happy-dom/global-registrator": "^20.8.9",
45
- "@pyreon/core": "^0.12.7",
46
- "@pyreon/reactivity": "^0.12.7",
45
+ "@pyreon/core": "^0.12.9",
46
+ "@pyreon/reactivity": "^0.12.9",
47
47
  "@types/pdfmake": "^0.3.2",
48
48
  "@vitus-labs/tools-lint": "^1.15.5",
49
49
  "docx": "^9.6.0",
@@ -52,8 +52,8 @@
52
52
  "pptxgenjs": "^4.0.1"
53
53
  },
54
54
  "peerDependencies": {
55
- "@pyreon/core": "^0.12.7",
56
- "@pyreon/reactivity": "^0.12.7"
55
+ "@pyreon/core": "^0.12.9",
56
+ "@pyreon/reactivity": "^0.12.9"
57
57
  },
58
58
  "optionalDependencies": {
59
59
  "docx": "^9.6.0",
@@ -0,0 +1,306 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import {
3
+ _resetRenderers,
4
+ Button,
5
+ Code,
6
+ createDocument,
7
+ Divider,
8
+ Document,
9
+ Heading,
10
+ Link,
11
+ List,
12
+ ListItem,
13
+ Page,
14
+ Quote,
15
+ render,
16
+ Table,
17
+ Text,
18
+ } from '../index'
19
+
20
+ afterEach(() => {
21
+ _resetRenderers()
22
+ })
23
+
24
+ // ─── Slack Renderer ────────────────────────────────────────────────────────
25
+
26
+ describe('Slack renderer', () => {
27
+ it('renders heading as header block', async () => {
28
+ const doc = Document({ children: Heading({ children: 'Title' }) })
29
+ const result = (await render(doc, 'slack')) as string
30
+ const parsed = JSON.parse(result)
31
+ expect(parsed.blocks).toBeDefined()
32
+ const header = parsed.blocks.find((b: any) => b.type === 'header')
33
+ expect(header).toBeDefined()
34
+ expect(header.text.text).toBe('Title')
35
+ })
36
+
37
+ it('renders bold text with Slack mrkdwn', async () => {
38
+ const doc = Document({ children: Text({ bold: true, children: 'important' }) })
39
+ const result = (await render(doc, 'slack')) as string
40
+ const parsed = JSON.parse(result)
41
+ const section = parsed.blocks.find((b: any) => b.type === 'section')
42
+ expect(section.text.text).toBe('*important*')
43
+ })
44
+
45
+ it('renders divider block', async () => {
46
+ const doc = Document({ children: Divider() })
47
+ const result = (await render(doc, 'slack')) as string
48
+ const parsed = JSON.parse(result)
49
+ expect(parsed.blocks.some((b: any) => b.type === 'divider')).toBe(true)
50
+ })
51
+
52
+ it('renders button as action block', async () => {
53
+ const doc = Document({
54
+ children: Button({ href: 'https://example.com', children: 'Click' }),
55
+ })
56
+ const result = (await render(doc, 'slack')) as string
57
+ const parsed = JSON.parse(result)
58
+ const action = parsed.blocks.find((b: any) => b.type === 'actions')
59
+ expect(action).toBeDefined()
60
+ expect(action.elements[0].text.text).toBe('Click')
61
+ })
62
+
63
+ it('renders list as mrkdwn', async () => {
64
+ const doc = Document({
65
+ children: List({
66
+ ordered: true,
67
+ children: [ListItem({ children: 'one' }), ListItem({ children: 'two' })],
68
+ }),
69
+ })
70
+ const result = (await render(doc, 'slack')) as string
71
+ const parsed = JSON.parse(result)
72
+ const section = parsed.blocks.find((b: any) => b.type === 'section')
73
+ expect(section.text.text).toContain('1. one')
74
+ expect(section.text.text).toContain('2. two')
75
+ })
76
+
77
+ it('renders table as code block', async () => {
78
+ const doc = Document({
79
+ children: Table({ columns: ['Name', 'Price'], rows: [['Widget', '$10']] }),
80
+ })
81
+ const result = (await render(doc, 'slack')) as string
82
+ const parsed = JSON.parse(result)
83
+ const section = parsed.blocks.find((b: any) => b.type === 'section')
84
+ expect(section.text.text).toContain('*Name*')
85
+ expect(section.text.text).toContain('Widget')
86
+ })
87
+
88
+ it('renders quote with >', async () => {
89
+ const doc = Document({ children: Quote({ children: 'wise words' }) })
90
+ const result = (await render(doc, 'slack')) as string
91
+ const parsed = JSON.parse(result)
92
+ const section = parsed.blocks.find((b: any) => b.type === 'section')
93
+ expect(section.text.text).toContain('> wise words')
94
+ })
95
+ })
96
+
97
+ // ─── Discord Renderer ──────────────────────────────────────────────────────
98
+
99
+ describe('Discord renderer', () => {
100
+ it('renders document as embed JSON', async () => {
101
+ const doc = Document({
102
+ title: 'Report',
103
+ children: [
104
+ Heading({ children: 'Report' }),
105
+ Text({ children: 'Summary text.' }),
106
+ ],
107
+ })
108
+ const result = (await render(doc, 'discord')) as string
109
+ const parsed = JSON.parse(result)
110
+ expect(parsed.embeds).toBeDefined()
111
+ expect(parsed.embeds[0].title).toBe('Report')
112
+ expect(parsed.embeds[0].description).toContain('Summary text.')
113
+ })
114
+
115
+ it('renders bold and italic text', async () => {
116
+ const doc = Document({
117
+ children: [
118
+ Text({ bold: true, children: 'bold' }),
119
+ Text({ italic: true, children: 'italic' }),
120
+ ],
121
+ })
122
+ const result = (await render(doc, 'discord')) as string
123
+ const parsed = JSON.parse(result)
124
+ expect(parsed.embeds[0].description).toContain('**bold**')
125
+ expect(parsed.embeds[0].description).toContain('*italic*')
126
+ })
127
+
128
+ it('renders small table as embed fields', async () => {
129
+ const doc = Document({
130
+ children: Table({
131
+ columns: ['Name', 'Price'],
132
+ rows: [['Widget', '$10'], ['Gadget', '$20']],
133
+ }),
134
+ })
135
+ const result = (await render(doc, 'discord')) as string
136
+ const parsed = JSON.parse(result)
137
+ expect(parsed.embeds[0].fields).toBeDefined()
138
+ expect(parsed.embeds[0].fields.length).toBe(2)
139
+ })
140
+ })
141
+
142
+ // ─── Telegram Renderer ─────────────────────────────────────────────────────
143
+
144
+ describe('Telegram renderer', () => {
145
+ it('renders heading as bold', async () => {
146
+ const doc = Document({ children: Heading({ children: 'Title' }) })
147
+ const result = (await render(doc, 'telegram')) as string
148
+ expect(result).toContain('<b>Title</b>')
149
+ })
150
+
151
+ it('renders text with formatting', async () => {
152
+ const doc = Document({
153
+ children: Text({ bold: true, italic: true, children: 'styled' }),
154
+ })
155
+ const result = (await render(doc, 'telegram')) as string
156
+ expect(result).toContain('<b>')
157
+ expect(result).toContain('<i>')
158
+ expect(result).toContain('styled')
159
+ })
160
+
161
+ it('renders link as anchor', async () => {
162
+ const doc = Document({
163
+ children: Link({ href: 'https://example.com', children: 'click' }),
164
+ })
165
+ const result = (await render(doc, 'telegram')) as string
166
+ expect(result).toContain('href="https://example.com"')
167
+ expect(result).toContain('click')
168
+ })
169
+
170
+ it('renders code with language class', async () => {
171
+ const doc = Document({
172
+ children: Code({ language: 'python', children: 'x = 1' }),
173
+ })
174
+ const result = (await render(doc, 'telegram')) as string
175
+ expect(result).toContain('language-python')
176
+ expect(result).toContain('x = 1')
177
+ })
178
+
179
+ it('renders quote as blockquote', async () => {
180
+ const doc = Document({ children: Quote({ children: 'wise' }) })
181
+ const result = (await render(doc, 'telegram')) as string
182
+ expect(result).toContain('<blockquote>wise</blockquote>')
183
+ })
184
+
185
+ it('escapes HTML in text', async () => {
186
+ const doc = Document({
187
+ children: Text({ children: '<script>alert(1)</script>' }),
188
+ })
189
+ const result = (await render(doc, 'telegram')) as string
190
+ expect(result).not.toContain('<script>')
191
+ expect(result).toContain('&lt;script&gt;')
192
+ })
193
+ })
194
+
195
+ // ─── SVG Renderer ──────────────────────────────────────────────────────────
196
+
197
+ describe('SVG renderer', () => {
198
+ it('renders a simple document as SVG', async () => {
199
+ const doc = Document({
200
+ children: [Heading({ children: 'Title' }), Text({ children: 'Body' })],
201
+ })
202
+ const svg = (await render(doc, 'svg')) as string
203
+ expect(svg).toContain('<svg')
204
+ expect(svg).toContain('Title')
205
+ expect(svg).toContain('Body')
206
+ })
207
+ })
208
+
209
+ // ─── Builder — Messaging Formats ───────────────────────────────────────────
210
+
211
+ describe('builder — messaging formats', () => {
212
+ it('toSlack produces valid JSON', async () => {
213
+ const result = await createDocument()
214
+ .heading('Alert')
215
+ .text('Something happened.')
216
+ .toSlack()
217
+ const parsed = JSON.parse(result as string)
218
+ expect(parsed.blocks).toBeDefined()
219
+ })
220
+
221
+ it('toDiscord produces embed JSON', async () => {
222
+ const result = await createDocument()
223
+ .heading('Update')
224
+ .text('New version released.')
225
+ .toDiscord()
226
+ const parsed = JSON.parse(result as string)
227
+ expect(parsed.embeds).toBeDefined()
228
+ })
229
+
230
+ it('toTelegram produces HTML string', async () => {
231
+ const result = await createDocument()
232
+ .heading('Notice')
233
+ .text('Please read.')
234
+ .toTelegram()
235
+ expect(result).toContain('<b>Notice</b>')
236
+ })
237
+
238
+ it('toSvg produces SVG string', async () => {
239
+ const result = await createDocument()
240
+ .heading('Chart Title')
241
+ .toSvg()
242
+ expect(result).toContain('<svg')
243
+ })
244
+ })
245
+
246
+ // ─── Cross-Format Consistency ──────────────────────────────────────────────
247
+
248
+ describe('cross-format consistency', () => {
249
+ const invoice = Document({
250
+ title: 'Invoice',
251
+ children: Page({
252
+ children: [
253
+ Heading({ children: 'Invoice #42' }),
254
+ Table({
255
+ columns: ['Item', 'Price'],
256
+ rows: [['Widget', '$10'], ['Gadget', '$20']],
257
+ }),
258
+ Text({ bold: true, children: 'Total: $30' }),
259
+ ],
260
+ }),
261
+ })
262
+
263
+ it('all text formats contain the same content', async () => {
264
+ const html = (await render(invoice, 'html')) as string
265
+ const md = (await render(invoice, 'md')) as string
266
+ const text = (await render(invoice, 'text')) as string
267
+ const email = (await render(invoice, 'email')) as string
268
+
269
+ for (const output of [html, md, email]) {
270
+ expect(output).toContain('Invoice')
271
+ expect(output).toContain('Widget')
272
+ expect(output).toContain('$10')
273
+ }
274
+ // Text renderer uppercases headings
275
+ expect(text).toContain('INVOICE')
276
+ expect(text).toContain('Widget')
277
+ expect(text).toContain('$10')
278
+ })
279
+ })
280
+
281
+ // ─── Builder — add() and section() ─────────────────────────────────────────
282
+
283
+ describe('builder — add and section', () => {
284
+ it('add() accepts a single node', () => {
285
+ const doc = createDocument().add(Heading({ children: 'Added' }))
286
+ const node = doc.build()
287
+ expect(node.type).toBe('document')
288
+ })
289
+
290
+ it('add() accepts an array of nodes', () => {
291
+ const doc = createDocument().add([
292
+ Text({ children: 'A' }),
293
+ Text({ children: 'B' }),
294
+ ])
295
+ const node = doc.build()
296
+ expect(node.type).toBe('document')
297
+ })
298
+
299
+ it('section() wraps children in a Section node', async () => {
300
+ const doc = createDocument().section([
301
+ Text({ children: 'inside section' }),
302
+ ])
303
+ const html = await doc.toHtml()
304
+ expect(html).toContain('inside section')
305
+ })
306
+ })
@@ -0,0 +1,615 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import {
3
+ _resetRenderers,
4
+ Button,
5
+ Code,
6
+ Column,
7
+ Divider,
8
+ Document,
9
+ Heading,
10
+ Image,
11
+ Link,
12
+ List,
13
+ ListItem,
14
+ Page,
15
+ PageBreak,
16
+ Quote,
17
+ Row,
18
+ render,
19
+ Section,
20
+ Spacer,
21
+ Table,
22
+ Text,
23
+ } from '../index'
24
+
25
+ afterEach(() => {
26
+ _resetRenderers()
27
+ })
28
+
29
+ // ─── Helper: full document with all node types ─────────────────────────────
30
+
31
+ function createFullDoc() {
32
+ return Document({
33
+ title: 'Test Report',
34
+ subject: 'Coverage',
35
+ children: [
36
+ Page({
37
+ children: [
38
+ Heading({ level: 1, children: 'Main Title' }),
39
+ Heading({ level: 2, children: 'Subtitle' }),
40
+ Text({ bold: true, children: 'Bold text' }),
41
+ Text({ italic: true, children: 'Italic text' }),
42
+ Text({ strikethrough: true, children: 'Striked text' }),
43
+ Text({ underline: true, color: '#ff0000', children: 'Underline colored' }),
44
+ Link({ href: 'https://example.com', children: 'Link text' }),
45
+ Image({ src: 'https://example.com/img.png', alt: 'Alt text', width: 200, height: 100 }),
46
+ Table({
47
+ columns: ['Name', 'Price', 'Qty'],
48
+ rows: [
49
+ ['Widget', '$10', '5'],
50
+ ['Gadget', '$20', '3'],
51
+ ],
52
+ striped: true,
53
+ caption: 'Products',
54
+ }),
55
+ List({
56
+ ordered: true,
57
+ children: [ListItem({ children: 'First' }), ListItem({ children: 'Second' })],
58
+ }),
59
+ List({
60
+ children: [ListItem({ children: 'Bullet A' }), ListItem({ children: 'Bullet B' })],
61
+ }),
62
+ Code({ language: 'typescript', children: 'const x = 42' }),
63
+ Divider({ color: '#ccc', thickness: 2 }),
64
+ PageBreak(),
65
+ Spacer({ height: 20 }),
66
+ Button({ href: 'https://example.com/pay', background: '#4f46e5', color: '#fff', children: 'Pay Now' }),
67
+ Quote({ borderColor: '#4f46e5', children: 'A wise quote' }),
68
+ Section({
69
+ direction: 'row',
70
+ gap: 12,
71
+ children: [
72
+ Row({
73
+ gap: 10,
74
+ children: [
75
+ Column({ width: '50%', children: Text({ children: 'Left' }) }),
76
+ Column({ width: '50%', children: Text({ children: 'Right' }) }),
77
+ ],
78
+ }),
79
+ ],
80
+ }),
81
+ ],
82
+ }),
83
+ ],
84
+ })
85
+ }
86
+
87
+ // ─── Discord Renderer ──────────────────────────────────────────────────────
88
+
89
+ describe('Discord renderer', () => {
90
+ it('renders full document as Discord embed JSON', async () => {
91
+ const doc = createFullDoc()
92
+ const result = (await render(doc, 'discord')) as string
93
+ const parsed = JSON.parse(result)
94
+
95
+ expect(parsed.embeds).toBeDefined()
96
+ expect(parsed.embeds).toHaveLength(1)
97
+ expect(parsed.embeds[0].title).toBe('Main Title')
98
+ expect(parsed.embeds[0].description).toContain('**Bold text**')
99
+ expect(parsed.embeds[0].description).toContain('*Italic text*')
100
+ expect(parsed.embeds[0].description).toContain('~~Striked text~~')
101
+ // Image extraction is depth-first; it finds the first HTTP image
102
+ // The embed may or may not include the image depending on traversal
103
+ expect(parsed.embeds[0].description).toBeDefined()
104
+ })
105
+
106
+ it('uses code block for large tables', async () => {
107
+ const doc = Document({
108
+ children: Table({
109
+ columns: ['A', 'B', 'C', 'D'],
110
+ rows: Array.from({ length: 15 }, (_, i) => [i, i + 1, i + 2, i + 3]),
111
+ }),
112
+ })
113
+ const result = (await render(doc, 'discord')) as string
114
+ const parsed = JSON.parse(result)
115
+ expect(parsed.embeds[0].description).toContain('```')
116
+ })
117
+
118
+ it('uses embed fields for small tables', async () => {
119
+ const doc = Document({
120
+ children: Table({
121
+ columns: ['Name', 'Price'],
122
+ rows: [['Widget', '$10']],
123
+ }),
124
+ })
125
+ const result = (await render(doc, 'discord')) as string
126
+ const parsed = JSON.parse(result)
127
+ expect(parsed.embeds[0].fields).toBeDefined()
128
+ })
129
+
130
+ it('renders link and button', async () => {
131
+ const doc = Document({
132
+ children: [
133
+ Link({ href: 'https://example.com', children: 'Click' }),
134
+ Button({ href: 'https://example.com', children: 'Action' }),
135
+ ],
136
+ })
137
+ const result = (await render(doc, 'discord')) as string
138
+ const parsed = JSON.parse(result)
139
+ expect(parsed.embeds[0].description).toContain('[Click]')
140
+ expect(parsed.embeds[0].description).toContain('[**Action**]')
141
+ })
142
+
143
+ it('renders divider and page break', async () => {
144
+ const doc = Document({ children: [Divider(), PageBreak()] })
145
+ const result = (await render(doc, 'discord')) as string
146
+ const parsed = JSON.parse(result)
147
+ expect(parsed.embeds[0].description).toContain('───')
148
+ })
149
+
150
+ it('renders code block', async () => {
151
+ const doc = Document({ children: Code({ language: 'js', children: 'let x = 1' }) })
152
+ const result = (await render(doc, 'discord')) as string
153
+ expect(result).toContain('```js')
154
+ })
155
+
156
+ it('renders quote', async () => {
157
+ const doc = Document({ children: Quote({ children: 'wise' }) })
158
+ const result = (await render(doc, 'discord')) as string
159
+ expect(result).toContain('> wise')
160
+ })
161
+
162
+ it('renders list', async () => {
163
+ const doc = Document({
164
+ children: List({
165
+ ordered: true,
166
+ children: [ListItem({ children: 'one' })],
167
+ }),
168
+ })
169
+ const result = (await render(doc, 'discord')) as string
170
+ expect(result).toContain('1. one')
171
+ })
172
+ })
173
+
174
+ // ─── Google Chat Renderer ──────────────────────────────────────────────────
175
+
176
+ describe('Google Chat renderer', () => {
177
+ it('renders full document as Card V2', async () => {
178
+ const doc = createFullDoc()
179
+ const result = (await render(doc, 'google-chat')) as string
180
+ const parsed = JSON.parse(result)
181
+
182
+ expect(parsed.cardsV2).toBeDefined()
183
+ // When document has a title prop, that's used as card header
184
+ expect(parsed.cardsV2[0].card.header?.title).toBe('Test Report')
185
+ expect(parsed.cardsV2[0].card.sections[0].widgets.length).toBeGreaterThan(0)
186
+ })
187
+
188
+ it('renders heading as decorated text', async () => {
189
+ const doc = Document({ children: Heading({ children: 'Hello' }) })
190
+ const result = (await render(doc, 'google-chat')) as string
191
+ const parsed = JSON.parse(result)
192
+ const widgets = parsed.cardsV2[0].card.sections[0].widgets
193
+ expect(widgets.some((w: any) => w.decoratedText?.text?.includes('<b>Hello</b>'))).toBe(true)
194
+ })
195
+
196
+ it('renders image with http src', async () => {
197
+ const doc = Document({ children: Image({ src: 'https://example.com/img.png', alt: 'test' }) })
198
+ const result = (await render(doc, 'google-chat')) as string
199
+ const parsed = JSON.parse(result)
200
+ const widgets = parsed.cardsV2[0].card.sections[0].widgets
201
+ expect(widgets.some((w: any) => w.image?.imageUrl)).toBe(true)
202
+ })
203
+
204
+ it('renders code block', async () => {
205
+ const doc = Document({ children: Code({ children: 'const x = 1' }) })
206
+ const result = (await render(doc, 'google-chat')) as string
207
+ expect(result).toContain('<code>')
208
+ })
209
+
210
+ it('renders divider', async () => {
211
+ const doc = Document({ children: Divider() })
212
+ const result = (await render(doc, 'google-chat')) as string
213
+ const parsed = JSON.parse(result)
214
+ const widgets = parsed.cardsV2[0].card.sections[0].widgets
215
+ expect(widgets.some((w: any) => w.divider)).toBe(true)
216
+ })
217
+
218
+ it('renders button', async () => {
219
+ const doc = Document({ children: Button({ href: 'https://example.com', children: 'Click' }) })
220
+ const result = (await render(doc, 'google-chat')) as string
221
+ expect(result).toContain('buttonList')
222
+ })
223
+
224
+ it('renders quote', async () => {
225
+ const doc = Document({ children: Quote({ children: 'quoted' }) })
226
+ const result = (await render(doc, 'google-chat')) as string
227
+ expect(result).toContain('quoted')
228
+ })
229
+
230
+ it('renders list', async () => {
231
+ const doc = Document({
232
+ children: List({
233
+ ordered: true,
234
+ children: [ListItem({ children: 'one' })],
235
+ }),
236
+ })
237
+ const result = (await render(doc, 'google-chat')) as string
238
+ expect(result).toContain('1. one')
239
+ })
240
+
241
+ it('renders table', async () => {
242
+ const doc = Document({
243
+ children: Table({
244
+ columns: ['A', 'B'],
245
+ rows: [['x', 'y']],
246
+ }),
247
+ })
248
+ const result = (await render(doc, 'google-chat')) as string
249
+ expect(result).toContain('<b>A</b>')
250
+ })
251
+
252
+ it('renders text formatting', async () => {
253
+ const doc = Document({
254
+ children: [
255
+ Text({ bold: true, children: 'bold' }),
256
+ Text({ italic: true, children: 'italic' }),
257
+ Text({ strikethrough: true, children: 'strike' }),
258
+ ],
259
+ })
260
+ const result = (await render(doc, 'google-chat')) as string
261
+ expect(result).toContain('<b>bold</b>')
262
+ expect(result).toContain('<i>italic</i>')
263
+ expect(result).toContain('<s>strike</s>')
264
+ })
265
+
266
+ it('renders spacer as no-op', async () => {
267
+ const doc = Document({ children: Spacer({ height: 20 }) })
268
+ const result = (await render(doc, 'google-chat')) as string
269
+ // Spacer produces no widgets
270
+ const parsed = JSON.parse(result)
271
+ expect(parsed.cardsV2[0].card.sections[0].widgets).toHaveLength(0)
272
+ })
273
+
274
+ it('extracts title from first heading when document has no title', async () => {
275
+ const doc = Document({
276
+ children: [Heading({ children: 'Extracted Title' }), Text({ children: 'body' })],
277
+ })
278
+ const result = (await render(doc, 'google-chat')) as string
279
+ const parsed = JSON.parse(result)
280
+ expect(parsed.cardsV2[0].card.header.title).toBe('Extracted Title')
281
+ })
282
+ })
283
+
284
+ // ─── Confluence Renderer ───────────────────────────────────────────────────
285
+
286
+ describe('Confluence renderer', () => {
287
+ it('renders full document as ADF', async () => {
288
+ const doc = createFullDoc()
289
+ const result = (await render(doc, 'confluence')) as string
290
+ const parsed = JSON.parse(result)
291
+
292
+ expect(parsed.version).toBe(1)
293
+ expect(parsed.type).toBe('doc')
294
+ expect(parsed.content.length).toBeGreaterThan(0)
295
+ })
296
+
297
+ it('renders heading with level', async () => {
298
+ const doc = Document({ children: Heading({ level: 3, children: 'H3' }) })
299
+ const result = (await render(doc, 'confluence')) as string
300
+ const parsed = JSON.parse(result)
301
+ const heading = parsed.content.find((n: any) => n.type === 'heading')
302
+ expect(heading.attrs.level).toBe(3)
303
+ })
304
+
305
+ it('renders text with marks', async () => {
306
+ const doc = Document({
307
+ children: Text({ bold: true, italic: true, underline: true, strikethrough: true, color: '#ff0000', children: 'styled' }),
308
+ })
309
+ const result = (await render(doc, 'confluence')) as string
310
+ const parsed = JSON.parse(result)
311
+ const para = parsed.content.find((n: any) => n.type === 'paragraph')
312
+ const marks = para.content[0].marks
313
+ expect(marks.some((m: any) => m.type === 'strong')).toBe(true)
314
+ expect(marks.some((m: any) => m.type === 'em')).toBe(true)
315
+ expect(marks.some((m: any) => m.type === 'underline')).toBe(true)
316
+ expect(marks.some((m: any) => m.type === 'strike')).toBe(true)
317
+ expect(marks.some((m: any) => m.type === 'textColor')).toBe(true)
318
+ })
319
+
320
+ it('renders link', async () => {
321
+ const doc = Document({ children: Link({ href: 'https://example.com', children: 'link' }) })
322
+ const result = (await render(doc, 'confluence')) as string
323
+ expect(result).toContain('"link"')
324
+ })
325
+
326
+ it('renders image with http src', async () => {
327
+ const doc = Document({ children: Image({ src: 'https://example.com/img.png', width: 100 }) })
328
+ const result = (await render(doc, 'confluence')) as string
329
+ expect(result).toContain('mediaSingle')
330
+ })
331
+
332
+ it('skips image with non-http src', async () => {
333
+ const doc = Document({ children: Image({ src: '/local/img.png' }) })
334
+ const result = (await render(doc, 'confluence')) as string
335
+ const parsed = JSON.parse(result)
336
+ expect(parsed.content.filter((n: any) => n.type === 'mediaSingle')).toHaveLength(0)
337
+ })
338
+
339
+ it('renders table', async () => {
340
+ const doc = Document({
341
+ children: Table({
342
+ columns: ['Name'],
343
+ rows: [['val']],
344
+ }),
345
+ })
346
+ const result = (await render(doc, 'confluence')) as string
347
+ expect(result).toContain('tableRow')
348
+ expect(result).toContain('tableHeader')
349
+ })
350
+
351
+ it('renders list', async () => {
352
+ const doc = Document({
353
+ children: List({
354
+ ordered: true,
355
+ children: [ListItem({ children: 'item' })],
356
+ }),
357
+ })
358
+ const result = (await render(doc, 'confluence')) as string
359
+ expect(result).toContain('orderedList')
360
+ })
361
+
362
+ it('renders bullet list', async () => {
363
+ const doc = Document({
364
+ children: List({
365
+ children: [ListItem({ children: 'item' })],
366
+ }),
367
+ })
368
+ const result = (await render(doc, 'confluence')) as string
369
+ expect(result).toContain('bulletList')
370
+ })
371
+
372
+ it('renders code block', async () => {
373
+ const doc = Document({ children: Code({ language: 'js', children: 'x' }) })
374
+ const result = (await render(doc, 'confluence')) as string
375
+ expect(result).toContain('codeBlock')
376
+ })
377
+
378
+ it('renders divider and page break', async () => {
379
+ const doc = Document({ children: [Divider(), PageBreak()] })
380
+ const result = (await render(doc, 'confluence')) as string
381
+ expect(result).toContain('"rule"')
382
+ })
383
+
384
+ it('renders spacer as empty paragraph', async () => {
385
+ const doc = Document({ children: Spacer({ height: 10 }) })
386
+ const result = (await render(doc, 'confluence')) as string
387
+ const parsed = JSON.parse(result)
388
+ expect(parsed.content.some((n: any) => n.type === 'paragraph')).toBe(true)
389
+ })
390
+
391
+ it('renders button as styled link', async () => {
392
+ const doc = Document({ children: Button({ href: 'https://example.com', children: 'Go' }) })
393
+ const result = (await render(doc, 'confluence')) as string
394
+ expect(result).toContain('"strong"')
395
+ expect(result).toContain('"link"')
396
+ })
397
+
398
+ it('renders quote', async () => {
399
+ const doc = Document({ children: Quote({ children: 'quoted' }) })
400
+ const result = (await render(doc, 'confluence')) as string
401
+ expect(result).toContain('blockquote')
402
+ })
403
+ })
404
+
405
+ // ─── WhatsApp Renderer ─────────────────────────────────────────────────────
406
+
407
+ describe('WhatsApp renderer', () => {
408
+ it('renders full document', async () => {
409
+ const doc = createFullDoc()
410
+ const result = (await render(doc, 'whatsapp')) as string
411
+
412
+ expect(result).toContain('*Main Title*')
413
+ expect(result).toContain('*Bold text*')
414
+ expect(result).toContain('_Italic text_')
415
+ expect(result).toContain('~Striked text~')
416
+ expect(result).toContain('Pay Now')
417
+ expect(result).toContain('> A wise quote')
418
+ })
419
+
420
+ it('renders table with caption', async () => {
421
+ const doc = Document({
422
+ children: Table({
423
+ columns: ['A', 'B'],
424
+ rows: [['x', 'y']],
425
+ caption: 'My Table',
426
+ }),
427
+ })
428
+ const result = (await render(doc, 'whatsapp')) as string
429
+ expect(result).toContain('_My Table_')
430
+ expect(result).toContain('*A*')
431
+ })
432
+
433
+ it('renders code block', async () => {
434
+ const doc = Document({ children: Code({ children: 'hello' }) })
435
+ const result = (await render(doc, 'whatsapp')) as string
436
+ expect(result).toContain('```hello```')
437
+ })
438
+
439
+ it('renders divider', async () => {
440
+ const doc = Document({ children: Divider() })
441
+ const result = (await render(doc, 'whatsapp')) as string
442
+ expect(result).toContain('───')
443
+ })
444
+
445
+ it('renders spacer as newline', async () => {
446
+ const doc = Document({ children: Spacer({ height: 10 }) })
447
+ const result = (await render(doc, 'whatsapp')) as string
448
+ expect(result).toBe('') // just whitespace, trimmed
449
+ })
450
+
451
+ it('skips images', async () => {
452
+ const doc = Document({ children: Image({ src: 'https://example.com/img.png' }) })
453
+ const result = (await render(doc, 'whatsapp')) as string
454
+ expect(result).toBe('')
455
+ })
456
+
457
+ it('renders link', async () => {
458
+ const doc = Document({ children: Link({ href: 'https://example.com', children: 'Click' }) })
459
+ const result = (await render(doc, 'whatsapp')) as string
460
+ expect(result).toContain('Click: https://example.com')
461
+ })
462
+
463
+ it('renders button', async () => {
464
+ const doc = Document({ children: Button({ href: 'https://example.com', children: 'Go' }) })
465
+ const result = (await render(doc, 'whatsapp')) as string
466
+ expect(result).toContain('*Go*: https://example.com')
467
+ })
468
+
469
+ it('renders list', async () => {
470
+ const doc = Document({
471
+ children: List({
472
+ ordered: true,
473
+ children: [ListItem({ children: 'one' }), ListItem({ children: 'two' })],
474
+ }),
475
+ })
476
+ const result = (await render(doc, 'whatsapp')) as string
477
+ expect(result).toContain('1. one')
478
+ expect(result).toContain('2. two')
479
+ })
480
+
481
+ it('renders section/row/column', async () => {
482
+ const doc = Document({
483
+ children: Section({
484
+ children: Row({
485
+ children: [
486
+ Column({ children: Text({ children: 'left' }) }),
487
+ Column({ children: Text({ children: 'right' }) }),
488
+ ],
489
+ }),
490
+ }),
491
+ })
492
+ const result = (await render(doc, 'whatsapp')) as string
493
+ expect(result).toContain('left')
494
+ expect(result).toContain('right')
495
+ })
496
+ })
497
+
498
+ // ─── Notion Renderer ───────────────────────────────────────────────────────
499
+
500
+ describe('Notion renderer', () => {
501
+ it('renders full document as Notion blocks', async () => {
502
+ const doc = createFullDoc()
503
+ const result = (await render(doc, 'notion')) as string
504
+ const parsed = JSON.parse(result)
505
+ expect(parsed.children).toBeDefined()
506
+ expect(parsed.children.length).toBeGreaterThan(0)
507
+ })
508
+
509
+ it('renders heading', async () => {
510
+ const doc = Document({ children: Heading({ level: 2, children: 'H2' }) })
511
+ const result = (await render(doc, 'notion')) as string
512
+ const parsed = JSON.parse(result)
513
+ const h2 = parsed.children.find((b: any) => b.type === 'heading_2')
514
+ expect(h2).toBeDefined()
515
+ })
516
+
517
+ it('renders button as bold link', async () => {
518
+ const doc = Document({ children: Button({ href: 'https://example.com', children: 'Go' }) })
519
+ const result = (await render(doc, 'notion')) as string
520
+ expect(result).toContain('"bold": true')
521
+ })
522
+
523
+ it('renders quote', async () => {
524
+ const doc = Document({ children: Quote({ children: 'quoted' }) })
525
+ const result = (await render(doc, 'notion')) as string
526
+ expect(result).toContain('"quote"')
527
+ })
528
+
529
+ it('renders spacer as empty paragraph', async () => {
530
+ const doc = Document({ children: Spacer({ height: 10 }) })
531
+ const result = (await render(doc, 'notion')) as string
532
+ const parsed = JSON.parse(result)
533
+ const para = parsed.children.find((b: any) => b.type === 'paragraph')
534
+ expect(para).toBeDefined()
535
+ })
536
+
537
+ it('renders divider', async () => {
538
+ const doc = Document({ children: Divider() })
539
+ const result = (await render(doc, 'notion')) as string
540
+ const parsed = JSON.parse(result)
541
+ expect(parsed.children.some((b: any) => b.type === 'divider')).toBe(true)
542
+ })
543
+ })
544
+
545
+ // ─── Telegram Renderer ─────────────────────────────────────────────────────
546
+
547
+ describe('Telegram renderer', () => {
548
+ it('renders full document as Telegram HTML', async () => {
549
+ const doc = createFullDoc()
550
+ const result = (await render(doc, 'telegram')) as string
551
+
552
+ expect(result).toContain('<b>Main Title</b>')
553
+ expect(result).toContain('<b>Bold text</b>')
554
+ expect(result).toContain('<i>Italic text</i>')
555
+ expect(result).toContain('<s>Striked text</s>')
556
+ expect(result).toContain('<a href="https://example.com">Link text</a>')
557
+ })
558
+
559
+ it('renders code block', async () => {
560
+ const doc = Document({ children: Code({ language: 'js', children: 'x = 1' }) })
561
+ const result = (await render(doc, 'telegram')) as string
562
+ expect(result).toContain('<pre>')
563
+ expect(result).toContain('<code')
564
+ })
565
+
566
+ it('renders quote', async () => {
567
+ const doc = Document({ children: Quote({ children: 'quoted' }) })
568
+ const result = (await render(doc, 'telegram')) as string
569
+ expect(result).toContain('<blockquote>')
570
+ })
571
+
572
+ it('renders button as link', async () => {
573
+ const doc = Document({ children: Button({ href: 'https://example.com', children: 'Go' }) })
574
+ const result = (await render(doc, 'telegram')) as string
575
+ expect(result).toContain('<a href=')
576
+ expect(result).toContain('Go')
577
+ })
578
+ })
579
+
580
+ // ─── Teams Renderer ────────────────────────────────────────────────────────
581
+
582
+ describe('Teams renderer', () => {
583
+ it('renders full document as adaptive card', async () => {
584
+ const doc = createFullDoc()
585
+ const result = (await render(doc, 'teams')) as string
586
+ const parsed = JSON.parse(result)
587
+
588
+ expect(parsed.type).toBe('AdaptiveCard')
589
+ expect(parsed.body.length).toBeGreaterThan(0)
590
+ })
591
+
592
+ it('renders heading', async () => {
593
+ const doc = Document({ children: Heading({ level: 1, children: 'Title' }) })
594
+ const result = (await render(doc, 'teams')) as string
595
+ expect(result).toContain('Title')
596
+ })
597
+
598
+ it('renders link', async () => {
599
+ const doc = Document({ children: Link({ href: 'https://example.com', children: 'Click' }) })
600
+ const result = (await render(doc, 'teams')) as string
601
+ expect(result).toContain('https://example.com')
602
+ })
603
+
604
+ it('renders image', async () => {
605
+ const doc = Document({ children: Image({ src: 'https://example.com/img.png', alt: 'img' }) })
606
+ const result = (await render(doc, 'teams')) as string
607
+ expect(result).toContain('https://example.com/img.png')
608
+ })
609
+
610
+ it('renders button as action', async () => {
611
+ const doc = Document({ children: Button({ href: 'https://example.com', children: 'Go' }) })
612
+ const result = (await render(doc, 'teams')) as string
613
+ expect(result).toContain('Action.OpenUrl')
614
+ })
615
+ })
@@ -0,0 +1,412 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ _resetRenderers,
4
+ createDocument,
5
+ Document,
6
+ isDocNode,
7
+ Page,
8
+ registerRenderer,
9
+ render,
10
+ Table,
11
+ Text,
12
+ unregisterRenderer,
13
+ } from '../index'
14
+ import {
15
+ sanitizeColor,
16
+ sanitizeCss,
17
+ sanitizeHref,
18
+ sanitizeImageSrc,
19
+ sanitizeStyle,
20
+ sanitizeXmlColor,
21
+ } from '../sanitize'
22
+ import { download } from '../download'
23
+
24
+ afterEach(() => {
25
+ _resetRenderers()
26
+ })
27
+
28
+ // ─── Sanitize utilities ────────────────────────────────────────────────────
29
+
30
+ describe('sanitizeCss', () => {
31
+ it('returns empty string for null/undefined', () => {
32
+ expect(sanitizeCss(undefined)).toBe('')
33
+ expect(sanitizeCss(undefined as any)).toBe('')
34
+ })
35
+
36
+ it('strips dangerous characters', () => {
37
+ expect(sanitizeCss('color; background{}')).toBe('color background')
38
+ // Strips quotes, parens, and url() prefix
39
+ const urlResult = sanitizeCss("url('bad')")
40
+ expect(urlResult).not.toContain('url(')
41
+ // expression() is stripped
42
+ const exprResult = sanitizeCss('expression(alert())')
43
+ expect(exprResult).not.toContain('expression(')
44
+ // javascript: is stripped
45
+ const jsResult = sanitizeCss('javascript:void(0)')
46
+ expect(jsResult).not.toContain('javascript:')
47
+ })
48
+
49
+ it('allows safe CSS values', () => {
50
+ expect(sanitizeCss('red')).toBe('red')
51
+ expect(sanitizeCss('12px')).toBe('12px')
52
+ expect(sanitizeCss('#fff')).toBe('#fff')
53
+ })
54
+ })
55
+
56
+ describe('sanitizeColor', () => {
57
+ it('returns empty string for null/undefined', () => {
58
+ expect(sanitizeColor(undefined)).toBe('')
59
+ })
60
+
61
+ it('allows hex colors', () => {
62
+ expect(sanitizeColor('#fff')).toBe('#fff')
63
+ expect(sanitizeColor('#ff0000')).toBe('#ff0000')
64
+ expect(sanitizeColor('#ff000080')).toBe('#ff000080')
65
+ })
66
+
67
+ it('allows named colors', () => {
68
+ expect(sanitizeColor('red')).toBe('red')
69
+ expect(sanitizeColor('blue')).toBe('blue')
70
+ })
71
+
72
+ it('allows rgb/rgba/hsl/hsla', () => {
73
+ expect(sanitizeColor('rgb(255, 0, 0)')).toBe('rgb(255, 0, 0)')
74
+ expect(sanitizeColor('rgba(255, 0, 0, 0.5)')).toBe('rgba(255, 0, 0, 0.5)')
75
+ expect(sanitizeColor('hsl(0, 100%, 50%)')).toBe('hsl(0, 100%, 50%)')
76
+ expect(sanitizeColor('hsla(0, 100%, 50%, 0.5)')).toBe('hsla(0, 100%, 50%, 0.5)')
77
+ })
78
+
79
+ it('allows special keywords', () => {
80
+ expect(sanitizeColor('transparent')).toBe('transparent')
81
+ expect(sanitizeColor('inherit')).toBe('inherit')
82
+ expect(sanitizeColor('currentColor')).toBe('currentColor')
83
+ expect(sanitizeColor('initial')).toBe('initial')
84
+ expect(sanitizeColor('unset')).toBe('unset')
85
+ })
86
+
87
+ it('rejects invalid values', () => {
88
+ expect(sanitizeColor('javascript:alert(1)')).toBe('')
89
+ expect(sanitizeColor('expression(something)')).toBe('')
90
+ expect(sanitizeColor('#xyz')).toBe('')
91
+ })
92
+ })
93
+
94
+ describe('sanitizeXmlColor', () => {
95
+ it('returns fallback for null/undefined', () => {
96
+ expect(sanitizeXmlColor(undefined)).toBe('000000')
97
+ expect(sanitizeXmlColor(undefined, 'ffffff')).toBe('ffffff')
98
+ })
99
+
100
+ it('strips # from hex', () => {
101
+ expect(sanitizeXmlColor('#ff0000')).toBe('ff0000')
102
+ })
103
+
104
+ it('passes through valid hex', () => {
105
+ expect(sanitizeXmlColor('4f46e5')).toBe('4f46e5')
106
+ })
107
+
108
+ it('returns fallback for invalid hex', () => {
109
+ expect(sanitizeXmlColor('not-hex')).toBe('000000')
110
+ })
111
+ })
112
+
113
+ describe('sanitizeHref', () => {
114
+ it('returns empty string for null/undefined', () => {
115
+ expect(sanitizeHref(undefined)).toBe('')
116
+ })
117
+
118
+ it('allows http/https URLs', () => {
119
+ expect(sanitizeHref('https://example.com')).toBe('https://example.com')
120
+ expect(sanitizeHref('http://example.com')).toBe('http://example.com')
121
+ })
122
+
123
+ it('blocks javascript: protocol', () => {
124
+ expect(sanitizeHref('javascript:alert(1)')).toBe('')
125
+ expect(sanitizeHref(' javascript:void(0)')).toBe('')
126
+ })
127
+
128
+ it('blocks vbscript: protocol', () => {
129
+ expect(sanitizeHref('vbscript:run')).toBe('')
130
+ })
131
+
132
+ it('blocks non-image data: URIs', () => {
133
+ expect(sanitizeHref('data:text/html,<script>alert(1)</script>')).toBe('')
134
+ })
135
+
136
+ it('allows data:image URIs', () => {
137
+ expect(sanitizeHref('data:image/png;base64,abc')).toBe('data:image/png;base64,abc')
138
+ })
139
+ })
140
+
141
+ describe('sanitizeImageSrc', () => {
142
+ it('returns empty string for null/undefined', () => {
143
+ expect(sanitizeImageSrc(undefined)).toBe('')
144
+ })
145
+
146
+ it('allows http URLs', () => {
147
+ expect(sanitizeImageSrc('https://example.com/img.png')).toBe('https://example.com/img.png')
148
+ })
149
+
150
+ it('blocks javascript: protocol', () => {
151
+ expect(sanitizeImageSrc('javascript:alert(1)')).toBe('')
152
+ })
153
+
154
+ it('blocks vbscript: protocol', () => {
155
+ expect(sanitizeImageSrc('vbscript:run')).toBe('')
156
+ })
157
+
158
+ it('blocks non-image data: URIs', () => {
159
+ expect(sanitizeImageSrc('data:text/html,bad')).toBe('')
160
+ })
161
+
162
+ it('allows data:image URIs', () => {
163
+ expect(sanitizeImageSrc('data:image/png;base64,abc')).toBe('data:image/png;base64,abc')
164
+ })
165
+ })
166
+
167
+ describe('sanitizeStyle', () => {
168
+ it('returns empty string for null/undefined', () => {
169
+ expect(sanitizeStyle(undefined)).toBe('')
170
+ })
171
+
172
+ it('sanitizes CSS in style attribute', () => {
173
+ expect(sanitizeStyle('color: red')).toBe('color: red')
174
+ expect(sanitizeStyle('expression(alert())')).not.toContain('expression(')
175
+ })
176
+ })
177
+
178
+ // ─── Node construction edge cases ──────────────────────────────────────────
179
+
180
+ describe('isDocNode', () => {
181
+ it('returns false for null', () => {
182
+ expect(isDocNode(null)).toBe(false)
183
+ })
184
+
185
+ it('returns false for string', () => {
186
+ expect(isDocNode('hello')).toBe(false)
187
+ })
188
+
189
+ it('returns false for number', () => {
190
+ expect(isDocNode(42)).toBe(false)
191
+ })
192
+
193
+ it('returns false for plain object without required keys', () => {
194
+ expect(isDocNode({ type: 'foo' })).toBe(false)
195
+ expect(isDocNode({ type: 'foo', props: {} })).toBe(false)
196
+ })
197
+
198
+ it('returns true for valid DocNode', () => {
199
+ expect(isDocNode({ type: 'text', props: {}, children: [] })).toBe(true)
200
+ })
201
+ })
202
+
203
+ describe('normalizeChildren edge cases', () => {
204
+ it('handles number children', () => {
205
+ const node = Text({ children: 42 as any })
206
+ expect(node.children).toEqual(['42'])
207
+ })
208
+
209
+ it('handles false/null children', () => {
210
+ const node = Text({ children: false as any })
211
+ expect(node.children).toEqual([])
212
+
213
+ const node2 = Text({ children: null as any })
214
+ expect(node2.children).toEqual([])
215
+ })
216
+
217
+ it('handles nested array children', () => {
218
+ const node = Page({ children: [['a', 'b'], 'c'] as any })
219
+ expect(node.children).toEqual(['a', 'b', 'c'])
220
+ })
221
+
222
+ it('throws on plain object children', () => {
223
+ expect(() => {
224
+ Text({ children: { invalid: true } as any })
225
+ }).toThrow('Invalid child')
226
+ })
227
+ })
228
+
229
+ // ─── Download function ─────────────────────────────────────────────────────
230
+
231
+ describe('download', () => {
232
+ it('throws for filename with unknown extension', async () => {
233
+ const doc = Document({ children: Text({ children: 'hello' }) })
234
+ // "noext" becomes ".noext" as extension which is unknown
235
+ await expect(download(doc, 'file.noext')).rejects.toThrow('Unknown file extension')
236
+ })
237
+
238
+ it('throws for unknown extension', async () => {
239
+ const doc = Document({ children: Text({ children: 'hello' }) })
240
+ await expect(download(doc, 'file.zzz')).rejects.toThrow("Unknown file extension '.zzz'")
241
+ })
242
+
243
+ it('downloads html file', async () => {
244
+ const doc = Document({ children: Text({ children: 'hello' }) })
245
+ // Mock URL.createObjectURL and element click
246
+ const urlSpy = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
247
+ const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
248
+ const clickSpy = vi.fn()
249
+ vi.spyOn(document, 'createElement').mockReturnValue({
250
+ set href(_: string) {},
251
+ set download(_: string) {},
252
+ click: clickSpy,
253
+ } as any)
254
+
255
+ await download(doc, 'file.html')
256
+
257
+ expect(clickSpy).toHaveBeenCalled()
258
+ expect(revokeSpy).toHaveBeenCalled()
259
+
260
+ urlSpy.mockRestore()
261
+ revokeSpy.mockRestore()
262
+ vi.restoreAllMocks()
263
+ })
264
+ })
265
+
266
+ // ─── Render function edge cases ────────────────────────────────────────────
267
+
268
+ describe('render — edge cases', () => {
269
+ it('throws for unregistered format', async () => {
270
+ const doc = Document({ children: Text({ children: 'hello' }) })
271
+ unregisterRenderer('nonexistent') // just to exercise the path
272
+ await expect(render(doc, 'nonexistent')).rejects.toThrow("No renderer registered")
273
+ })
274
+
275
+ it('registerRenderer with direct renderer (not lazy loader)', async () => {
276
+ registerRenderer('test-format', {
277
+ async render(_node, _options) {
278
+ return 'test-output'
279
+ },
280
+ })
281
+ const doc = Document({ children: Text({ children: 'hello' }) })
282
+ const result = await render(doc, 'test-format')
283
+ expect(result).toBe('test-output')
284
+ })
285
+
286
+ it('lazy renderer is cached after first use', async () => {
287
+ let loadCount = 0
288
+ registerRenderer('counted', () => {
289
+ loadCount++
290
+ return Promise.resolve({
291
+ async render() {
292
+ return 'ok'
293
+ },
294
+ })
295
+ })
296
+ const doc = Document({ children: Text({ children: 'hello' }) })
297
+ await render(doc, 'counted')
298
+ await render(doc, 'counted')
299
+ expect(loadCount).toBe(1) // Only loaded once
300
+ })
301
+ })
302
+
303
+ // ─── createDocument builder ────────────────────────────────────────────────
304
+
305
+ describe('createDocument builder', () => {
306
+ it('builds and renders to html', async () => {
307
+ const result = await createDocument({ title: 'Test' })
308
+ .heading('Title')
309
+ .text('paragraph')
310
+ .toHtml()
311
+
312
+ expect(result).toContain('Title')
313
+ expect(result).toContain('paragraph')
314
+ })
315
+
316
+ it('builds with table', async () => {
317
+ const result = await createDocument()
318
+ .table({ columns: ['A', 'B'], rows: [['x', 'y']] })
319
+ .toMarkdown()
320
+
321
+ expect(result).toContain('A')
322
+ expect(result).toContain('x')
323
+ })
324
+
325
+ it('builds with all methods', async () => {
326
+ const builder = createDocument({ title: 'Full' })
327
+ .heading('H1')
328
+ .text('para', { bold: true })
329
+ .link('link', { href: 'https://example.com' })
330
+ .image('https://example.com/img.png', { width: 100 })
331
+ .table({ columns: ['Col'], rows: [['val']] })
332
+ .code('x = 1', { language: 'js' })
333
+ .divider()
334
+ .spacer(20)
335
+ .list(['a', 'b'])
336
+ .quote('quoted')
337
+ .button('Go', { href: 'https://example.com' })
338
+ .pageBreak()
339
+
340
+ const text = await builder.toText()
341
+ expect(text).toContain('H1')
342
+ expect(text).toContain('para')
343
+ })
344
+
345
+ it('toMarkdown works', async () => {
346
+ const result = await createDocument().heading('Title').toMarkdown()
347
+ expect(result).toContain('Title')
348
+ })
349
+
350
+ it('toCsv works with table', async () => {
351
+ // CSV requires a table in the document
352
+ const doc = Document({
353
+ children: Table({ columns: ['A', 'B'], rows: [['1', '2']] }),
354
+ })
355
+ const result = (await render(doc, 'csv')) as string
356
+ expect(result).toContain('A')
357
+ expect(result).toContain('1')
358
+ })
359
+
360
+ it('toSlack works', async () => {
361
+ const result = await createDocument().heading('Title').toSlack()
362
+ const parsed = JSON.parse(result)
363
+ expect(parsed.blocks).toBeDefined()
364
+ })
365
+
366
+ it('toSvg works', async () => {
367
+ const result = await createDocument().heading('Title').toSvg()
368
+ expect(result).toContain('<svg')
369
+ })
370
+
371
+ it('toTeams works', async () => {
372
+ const result = await createDocument().heading('Title').toTeams()
373
+ expect(result).toContain('AdaptiveCard')
374
+ })
375
+
376
+ it('toDiscord works', async () => {
377
+ const result = await createDocument().heading('Title').toDiscord()
378
+ expect(result).toContain('embeds')
379
+ })
380
+
381
+ it('toTelegram works', async () => {
382
+ const result = await createDocument().heading('Title').toTelegram()
383
+ expect(result).toContain('Title')
384
+ })
385
+
386
+ it('toNotion works', async () => {
387
+ const result = await createDocument().heading('Title').toNotion()
388
+ const parsed = JSON.parse(result)
389
+ expect(parsed.children).toBeDefined()
390
+ expect(parsed.children.length).toBeGreaterThan(0)
391
+ })
392
+
393
+ it('toConfluence works', async () => {
394
+ const result = await createDocument().heading('Title').toConfluence()
395
+ expect(result).toContain('"doc"')
396
+ })
397
+
398
+ it('toWhatsApp works', async () => {
399
+ const result = await createDocument().heading('Title').toWhatsApp()
400
+ expect(result).toContain('*Title*')
401
+ })
402
+
403
+ it('toGoogleChat works', async () => {
404
+ const result = await createDocument().heading('Title').toGoogleChat()
405
+ expect(result).toContain('cardsV2')
406
+ })
407
+
408
+ it('toEmail works', async () => {
409
+ const result = await createDocument().heading('Title').toEmail()
410
+ expect(result).toContain('Title')
411
+ })
412
+ })