@pyreon/document 0.12.8 → 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.
|
|
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.
|
|
46
|
-
"@pyreon/reactivity": "^0.12.
|
|
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.
|
|
56
|
-
"@pyreon/reactivity": "^0.12.
|
|
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('<script>')
|
|
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
|
+
})
|