@pyreon/document 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/lib/analysis/index.js.html +5406 -0
  3. package/lib/chunk-ErZ26oRB.js +48 -0
  4. package/lib/confluence-Va8e7RxQ.js +192 -0
  5. package/lib/confluence-Va8e7RxQ.js.map +1 -0
  6. package/lib/csv-2c38ub-Y.js +32 -0
  7. package/lib/csv-2c38ub-Y.js.map +1 -0
  8. package/lib/discord-DAoUZqvE.js +134 -0
  9. package/lib/discord-DAoUZqvE.js.map +1 -0
  10. package/lib/dist-BsqdI2nY.js +20179 -0
  11. package/lib/dist-BsqdI2nY.js.map +1 -0
  12. package/lib/docx-CorFwEH9.js +450 -0
  13. package/lib/docx-CorFwEH9.js.map +1 -0
  14. package/lib/email-Bn_Brjdp.js +131 -0
  15. package/lib/email-Bn_Brjdp.js.map +1 -0
  16. package/lib/exceljs-BoIDUUaw.js +34377 -0
  17. package/lib/exceljs-BoIDUUaw.js.map +1 -0
  18. package/lib/google-chat-B6I017I1.js +125 -0
  19. package/lib/google-chat-B6I017I1.js.map +1 -0
  20. package/lib/html-De_iS_f0.js +151 -0
  21. package/lib/html-De_iS_f0.js.map +1 -0
  22. package/lib/index.js +619 -0
  23. package/lib/index.js.map +1 -0
  24. package/lib/markdown-BYC_3C9i.js +75 -0
  25. package/lib/markdown-BYC_3C9i.js.map +1 -0
  26. package/lib/notion-DHaQHO6P.js +187 -0
  27. package/lib/notion-DHaQHO6P.js.map +1 -0
  28. package/lib/pdf-CDPc5Itc.js +419 -0
  29. package/lib/pdf-CDPc5Itc.js.map +1 -0
  30. package/lib/pdfmake-DnmLxK4Q.js +55511 -0
  31. package/lib/pdfmake-DnmLxK4Q.js.map +1 -0
  32. package/lib/pptx-DKQU6bjq.js +252 -0
  33. package/lib/pptx-DKQU6bjq.js.map +1 -0
  34. package/lib/pptxgen.es-COcgXsyx.js +5697 -0
  35. package/lib/pptxgen.es-COcgXsyx.js.map +1 -0
  36. package/lib/slack-CJRJgkag.js +139 -0
  37. package/lib/slack-CJRJgkag.js.map +1 -0
  38. package/lib/svg-BM8biZmL.js +187 -0
  39. package/lib/svg-BM8biZmL.js.map +1 -0
  40. package/lib/teams-S99tonRG.js +176 -0
  41. package/lib/teams-S99tonRG.js.map +1 -0
  42. package/lib/telegram-CbEO_2PN.js +77 -0
  43. package/lib/telegram-CbEO_2PN.js.map +1 -0
  44. package/lib/text-B5U8ucRr.js +75 -0
  45. package/lib/text-B5U8ucRr.js.map +1 -0
  46. package/lib/types/index.d.ts +528 -0
  47. package/lib/types/index.d.ts.map +1 -0
  48. package/lib/vfs_fonts-Df1kkZ4Y.js +19 -0
  49. package/lib/vfs_fonts-Df1kkZ4Y.js.map +1 -0
  50. package/lib/whatsapp-DJ2D1jGG.js +64 -0
  51. package/lib/whatsapp-DJ2D1jGG.js.map +1 -0
  52. package/lib/xlsx-D47x-gZ5.js +199 -0
  53. package/lib/xlsx-D47x-gZ5.js.map +1 -0
  54. package/package.json +62 -0
  55. package/src/builder.ts +266 -0
  56. package/src/download.ts +76 -0
  57. package/src/env.d.ts +17 -0
  58. package/src/index.ts +98 -0
  59. package/src/nodes.ts +315 -0
  60. package/src/render.ts +222 -0
  61. package/src/renderers/confluence.ts +231 -0
  62. package/src/renderers/csv.ts +67 -0
  63. package/src/renderers/discord.ts +192 -0
  64. package/src/renderers/docx.ts +612 -0
  65. package/src/renderers/email.ts +230 -0
  66. package/src/renderers/google-chat.ts +211 -0
  67. package/src/renderers/html.ts +225 -0
  68. package/src/renderers/markdown.ts +144 -0
  69. package/src/renderers/notion.ts +264 -0
  70. package/src/renderers/pdf.ts +427 -0
  71. package/src/renderers/pptx.ts +353 -0
  72. package/src/renderers/slack.ts +192 -0
  73. package/src/renderers/svg.ts +254 -0
  74. package/src/renderers/teams.ts +234 -0
  75. package/src/renderers/telegram.ts +137 -0
  76. package/src/renderers/text.ts +154 -0
  77. package/src/renderers/whatsapp.ts +121 -0
  78. package/src/renderers/xlsx.ts +342 -0
  79. package/src/tests/document.test.ts +2920 -0
  80. package/src/types.ts +291 -0
@@ -0,0 +1,230 @@
1
+ import type {
2
+ DocChild,
3
+ DocNode,
4
+ DocumentRenderer,
5
+ RenderOptions,
6
+ TableColumn,
7
+ } from '../types'
8
+
9
+ /**
10
+ * Email renderer — generates table-based HTML with inline styles
11
+ * that works across Gmail, Outlook, Apple Mail, and other email clients.
12
+ *
13
+ * Key constraints:
14
+ * - No CSS classes (Gmail strips <style> tags)
15
+ * - Table-based layout (no flexbox/grid)
16
+ * - All styles inline
17
+ * - VML buttons for Outlook
18
+ * - Max width 600px for compatibility
19
+ */
20
+
21
+ function esc(str: string): string {
22
+ return str
23
+ .replace(/&/g, '&amp;')
24
+ .replace(/</g, '&lt;')
25
+ .replace(/>/g, '&gt;')
26
+ .replace(/"/g, '&quot;')
27
+ }
28
+
29
+ function resolveColumn(col: string | TableColumn): TableColumn {
30
+ return typeof col === 'string' ? { header: col } : col
31
+ }
32
+
33
+ function renderChild(child: DocChild): string {
34
+ if (typeof child === 'string') return esc(child)
35
+ return renderNode(child)
36
+ }
37
+
38
+ function renderChildren(children: DocChild[]): string {
39
+ return children.map(renderChild).join('')
40
+ }
41
+
42
+ function wrapInTable(content: string, style = ''): string {
43
+ return `<table width="100%" cellpadding="0" cellspacing="0" border="0"${style ? ` style="${style}"` : ''}><tr><td>${content}</td></tr></table>`
44
+ }
45
+
46
+ function renderNode(node: DocNode): string {
47
+ const p = node.props
48
+
49
+ switch (node.type) {
50
+ case 'document': {
51
+ const title = p.title ? `<title>${esc(p.title as string)}</title>` : ''
52
+ const preview = p.subject
53
+ ? `<div style="display:none;max-height:0;overflow:hidden">${esc(p.subject as string)}</div>`
54
+ : ''
55
+ return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">${title}<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]--></head><body style="margin:0;padding:0;background-color:#f4f4f4;font-family:Arial,Helvetica,sans-serif">${preview}<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f4f4f4"><tr><td align="center" style="padding:20px 0"><table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color:#ffffff;max-width:600px;width:100%"><tr><td>${renderChildren(node.children)}</td></tr></table></td></tr></table></body></html>`
56
+ }
57
+
58
+ case 'page':
59
+ // In email, pages are just content sections
60
+ return renderChildren(node.children)
61
+
62
+ case 'section': {
63
+ const bg = p.background ? `background-color:${p.background};` : ''
64
+ const pad = p.padding
65
+ ? `padding:${typeof p.padding === 'number' ? `${p.padding}px` : Array.isArray(p.padding) ? (p.padding as number[]).map((v) => `${v}px`).join(' ') : '0'}`
66
+ : 'padding:0'
67
+ const radius = p.borderRadius ? `border-radius:${p.borderRadius}px;` : ''
68
+
69
+ if (p.direction === 'row') {
70
+ // Row layout via nested table
71
+ const children = node.children.filter(
72
+ (c): c is DocNode => typeof c !== 'string',
73
+ )
74
+ const colWidth = Math.floor(100 / Math.max(children.length, 1))
75
+ return `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="${bg}${radius}${pad}"><tr>${children.map((child) => `<td width="${colWidth}%" valign="top" style="padding:${(p.gap as number | undefined) ? `0 ${(p.gap as number) / 2}px` : '0'}">${renderNode(child)}</td>`).join('')}</tr></table>`
76
+ }
77
+
78
+ return wrapInTable(renderChildren(node.children), `${bg}${radius}${pad}`)
79
+ }
80
+
81
+ case 'row': {
82
+ const children = node.children.filter(
83
+ (c): c is DocNode => typeof c !== 'string',
84
+ )
85
+ const gap = (p.gap as number) ?? 0
86
+ return `<table width="100%" cellpadding="0" cellspacing="0" border="0"><tr>${children.map((child) => `<td valign="top" style="padding:0 ${gap / 2}px">${renderNode(child)}</td>`).join('')}</tr></table>`
87
+ }
88
+
89
+ case 'column':
90
+ return renderChildren(node.children)
91
+
92
+ case 'heading': {
93
+ const level = (p.level as number) ?? 1
94
+ const sizes: Record<number, number> = {
95
+ 1: 28,
96
+ 2: 24,
97
+ 3: 20,
98
+ 4: 18,
99
+ 5: 16,
100
+ 6: 14,
101
+ }
102
+ const size = sizes[level] ?? 24
103
+ const color = (p.color as string) ?? '#000000'
104
+ const align = (p.align as string) ?? 'left'
105
+ return `<h${level} style="margin:0 0 12px 0;font-size:${size}px;color:${color};text-align:${align};font-weight:bold;line-height:1.3">${renderChildren(node.children)}</h${level}>`
106
+ }
107
+
108
+ case 'text': {
109
+ const size = (p.size as number) ?? 14
110
+ const color = (p.color as string) ?? '#333333'
111
+ const weight = p.bold ? 'bold' : 'normal'
112
+ const style = p.italic ? 'italic' : 'normal'
113
+ const decoration = p.underline
114
+ ? 'underline'
115
+ : p.strikethrough
116
+ ? 'line-through'
117
+ : 'none'
118
+ const align = (p.align as string) ?? 'left'
119
+ const lh = (p.lineHeight as number) ?? 1.5
120
+ return `<p style="margin:0 0 12px 0;font-size:${size}px;color:${color};font-weight:${weight};font-style:${style};text-decoration:${decoration};text-align:${align};line-height:${lh}">${renderChildren(node.children)}</p>`
121
+ }
122
+
123
+ case 'link':
124
+ return `<a href="${esc(p.href as string)}" style="color:${(p.color as string) ?? '#4f46e5'};text-decoration:underline" target="_blank">${renderChildren(node.children)}</a>`
125
+
126
+ case 'image': {
127
+ const align = (p.align as string) ?? 'left'
128
+ const img = `<img src="${esc(p.src as string)}"${p.width ? ` width="${p.width}"` : ''}${p.height ? ` height="${p.height}"` : ''} alt="${esc((p.alt as string) ?? '')}" style="display:block;outline:none;border:none;text-decoration:none${p.width ? `;max-width:${p.width}px` : ''}" />`
129
+ if (p.caption) {
130
+ return `<table cellpadding="0" cellspacing="0" border="0"${align === 'center' ? ' align="center"' : ''}><tr><td>${img}</td></tr><tr><td style="font-size:12px;color:#666;padding-top:4px;text-align:center">${esc(p.caption as string)}</td></tr></table>`
131
+ }
132
+ if (align === 'center')
133
+ return `<div style="text-align:center">${img}</div>`
134
+ if (align === 'right') return `<div style="text-align:right">${img}</div>`
135
+ return img
136
+ }
137
+
138
+ case 'table': {
139
+ const columns = ((p.columns ?? []) as (string | TableColumn)[]).map(
140
+ resolveColumn,
141
+ )
142
+ const rows = (p.rows ?? []) as (string | number)[][]
143
+ const hs = p.headerStyle as
144
+ | { background?: string; color?: string; bold?: boolean }
145
+ | undefined
146
+ const striped = p.striped as boolean | undefined
147
+
148
+ let html =
149
+ '<table width="100%" cellpadding="8" cellspacing="0" border="0" style="border-collapse:collapse">'
150
+ if (p.caption)
151
+ html += `<caption style="font-size:12px;color:#666;padding:8px;text-align:left">${esc(p.caption as string)}</caption>`
152
+
153
+ html += '<tr>'
154
+ for (const col of columns) {
155
+ const bg = hs?.background
156
+ ? `background-color:${hs.background};`
157
+ : 'background-color:#f5f5f5;'
158
+ const color = hs?.color ? `color:${hs.color};` : ''
159
+ const align = col.align ? `text-align:${col.align};` : ''
160
+ const width = col.width
161
+ ? `width:${typeof col.width === 'number' ? `${col.width}px` : col.width};`
162
+ : ''
163
+ html += `<th style="${bg}${color}font-weight:bold;${align}${width}padding:8px;border-bottom:2px solid #ddd">${esc(col.header)}</th>`
164
+ }
165
+ html += '</tr>'
166
+
167
+ for (let i = 0; i < rows.length; i++) {
168
+ const bg = striped && i % 2 === 1 ? 'background-color:#f9f9f9;' : ''
169
+ html += '<tr>'
170
+ for (let j = 0; j < columns.length; j++) {
171
+ const col = columns[j]
172
+ const align = col?.align ? `text-align:${col.align};` : ''
173
+ html += `<td style="${bg}${align}padding:8px;border-bottom:1px solid #eee">${esc(String(rows[i]?.[j] ?? ''))}</td>`
174
+ }
175
+ html += '</tr>'
176
+ }
177
+ html += '</table>'
178
+ return html
179
+ }
180
+
181
+ case 'list': {
182
+ const tag = p.ordered ? 'ol' : 'ul'
183
+ return `<${tag} style="margin:0 0 12px 0;padding-left:24px">${renderChildren(node.children)}</${tag}>`
184
+ }
185
+
186
+ case 'list-item':
187
+ return `<li style="margin:0 0 4px 0;font-size:14px;color:#333">${renderChildren(node.children)}</li>`
188
+
189
+ case 'code':
190
+ return `<pre style="background-color:#f5f5f5;padding:12px;border-radius:4px;font-family:Courier New,monospace;font-size:13px;color:#333;overflow-x:auto;margin:0 0 12px 0"><code>${esc(renderChildren(node.children))}</code></pre>`
191
+
192
+ case 'divider': {
193
+ const color = (p.color as string) ?? '#dddddd'
194
+ const thickness = (p.thickness as number) ?? 1
195
+ return `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0"><tr><td style="border-top:${thickness}px solid ${color};font-size:0;line-height:0">&nbsp;</td></tr></table>`
196
+ }
197
+
198
+ case 'page-break':
199
+ return `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:24px 0"><tr><td style="border-top:2px solid #dddddd;font-size:0;line-height:0">&nbsp;</td></tr></table>`
200
+
201
+ case 'spacer':
202
+ return `<div style="height:${p.height}px;line-height:${p.height}px;font-size:0">&nbsp;</div>`
203
+
204
+ case 'button': {
205
+ const bg = (p.background as string) ?? '#4f46e5'
206
+ const color = (p.color as string) ?? '#ffffff'
207
+ const radius = (p.borderRadius as number) ?? 4
208
+ const href = esc(p.href as string)
209
+ const text = renderChildren(node.children)
210
+ const align = (p.align as string) ?? 'left'
211
+
212
+ // Bulletproof button — works in Outlook via VML, CSS everywhere else
213
+ return `<div style="text-align:${align};margin:12px 0"><!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="${href}" style="height:44px;v-text-anchor:middle;width:200px" arcsize="10%" strokecolor="${bg}" fillcolor="${bg}"><w:anchorlock/><center style="color:${color};font-family:Arial,sans-serif;font-size:14px;font-weight:bold">${text}</center></v:roundrect><![endif]--><!--[if !mso]><!--><a href="${href}" style="display:inline-block;background-color:${bg};color:${color};padding:12px 24px;border-radius:${radius}px;text-decoration:none;font-weight:bold;font-size:14px;font-family:Arial,sans-serif" target="_blank">${text}</a><!--<![endif]--></div>`
214
+ }
215
+
216
+ case 'quote': {
217
+ const borderColor = (p.borderColor as string) ?? '#dddddd'
218
+ return `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:12px 0"><tr><td style="border-left:4px solid ${borderColor};padding:12px 20px;color:#555555;font-style:italic">${renderChildren(node.children)}</td></tr></table>`
219
+ }
220
+
221
+ default:
222
+ return renderChildren(node.children)
223
+ }
224
+ }
225
+
226
+ export const emailRenderer: DocumentRenderer = {
227
+ async render(node: DocNode, _options?: RenderOptions): Promise<string> {
228
+ return renderNode(node)
229
+ },
230
+ }
@@ -0,0 +1,211 @@
1
+ import type {
2
+ DocChild,
3
+ DocNode,
4
+ DocumentRenderer,
5
+ RenderOptions,
6
+ TableColumn,
7
+ } from '../types'
8
+
9
+ /**
10
+ * Google Chat renderer — outputs Card V2 JSON for Google Chat API.
11
+ * Cards can be sent via webhooks, Chat API, or Apps Script.
12
+ */
13
+
14
+ function resolveColumn(col: string | TableColumn): TableColumn {
15
+ return typeof col === 'string' ? { header: col } : col
16
+ }
17
+
18
+ function getTextContent(children: DocChild[]): string {
19
+ return children
20
+ .map((c) =>
21
+ typeof c === 'string' ? c : getTextContent((c as DocNode).children),
22
+ )
23
+ .join('')
24
+ }
25
+
26
+ interface CardWidget {
27
+ [key: string]: unknown
28
+ }
29
+
30
+ function nodeToWidgets(node: DocNode): CardWidget[] {
31
+ const p = node.props
32
+ const widgets: CardWidget[] = []
33
+
34
+ switch (node.type) {
35
+ case 'document':
36
+ case 'page':
37
+ case 'section':
38
+ case 'row':
39
+ case 'column':
40
+ for (const child of node.children) {
41
+ if (typeof child !== 'string') {
42
+ widgets.push(...nodeToWidgets(child))
43
+ }
44
+ }
45
+ break
46
+
47
+ case 'heading': {
48
+ const text = getTextContent(node.children)
49
+ widgets.push({
50
+ decoratedText: {
51
+ topLabel: '',
52
+ text: `<b>${text}</b>`,
53
+ wrapText: true,
54
+ },
55
+ })
56
+ break
57
+ }
58
+
59
+ case 'text': {
60
+ let text = getTextContent(node.children)
61
+ if (p.bold) text = `<b>${text}</b>`
62
+ if (p.italic) text = `<i>${text}</i>`
63
+ if (p.strikethrough) text = `<s>${text}</s>`
64
+ widgets.push({
65
+ textParagraph: { text },
66
+ })
67
+ break
68
+ }
69
+
70
+ case 'link': {
71
+ const href = p.href as string
72
+ const text = getTextContent(node.children)
73
+ widgets.push({
74
+ textParagraph: { text: `<a href="${href}">${text}</a>` },
75
+ })
76
+ break
77
+ }
78
+
79
+ case 'image': {
80
+ const src = p.src as string
81
+ if (src.startsWith('http')) {
82
+ widgets.push({
83
+ image: {
84
+ imageUrl: src,
85
+ altText: (p.alt as string) ?? 'Image',
86
+ },
87
+ })
88
+ }
89
+ break
90
+ }
91
+
92
+ case 'table': {
93
+ const columns = ((p.columns ?? []) as (string | TableColumn)[]).map(
94
+ resolveColumn,
95
+ )
96
+ const rows = (p.rows ?? []) as (string | number)[][]
97
+
98
+ // Google Chat Cards don't have native tables — use grid or formatted text
99
+ const header = columns.map((c) => `<b>${c.header}</b>`).join(' | ')
100
+ const body = rows
101
+ .map((row) => row.map((c) => String(c ?? '')).join(' | '))
102
+ .join('\n')
103
+
104
+ widgets.push({
105
+ textParagraph: { text: `${header}\n${body}` },
106
+ })
107
+ break
108
+ }
109
+
110
+ case 'list': {
111
+ const ordered = p.ordered as boolean | undefined
112
+ const items = node.children
113
+ .filter((c): c is DocNode => typeof c !== 'string')
114
+ .map((item, i) => {
115
+ const prefix = ordered ? `${i + 1}.` : '•'
116
+ return `${prefix} ${getTextContent(item.children)}`
117
+ })
118
+ .join('\n')
119
+ widgets.push({
120
+ textParagraph: { text: items },
121
+ })
122
+ break
123
+ }
124
+
125
+ case 'code': {
126
+ const text = getTextContent(node.children)
127
+ widgets.push({
128
+ textParagraph: {
129
+ text: `<font color="#333333"><code>${text}</code></font>`,
130
+ },
131
+ })
132
+ break
133
+ }
134
+
135
+ case 'divider':
136
+ case 'page-break':
137
+ widgets.push({ divider: {} })
138
+ break
139
+
140
+ case 'spacer':
141
+ // No direct equivalent — skip
142
+ break
143
+
144
+ case 'button': {
145
+ const href = p.href as string
146
+ const text = getTextContent(node.children)
147
+ widgets.push({
148
+ buttonList: {
149
+ buttons: [
150
+ {
151
+ text,
152
+ onClick: { openLink: { url: href } },
153
+ color: {
154
+ red: 0.31,
155
+ green: 0.27,
156
+ blue: 0.89,
157
+ alpha: 1,
158
+ },
159
+ },
160
+ ],
161
+ },
162
+ })
163
+ break
164
+ }
165
+
166
+ case 'quote': {
167
+ const text = getTextContent(node.children)
168
+ widgets.push({
169
+ textParagraph: { text: `<i>"${text}"</i>` },
170
+ })
171
+ break
172
+ }
173
+ }
174
+
175
+ return widgets
176
+ }
177
+
178
+ export const googleChatRenderer: DocumentRenderer = {
179
+ async render(node: DocNode, _options?: RenderOptions): Promise<string> {
180
+ const widgets = nodeToWidgets(node)
181
+
182
+ // Extract title from first heading or document title
183
+ let title = (node.props.title as string) ?? ''
184
+ if (!title) {
185
+ const firstHeading = node.children.find(
186
+ (c): c is DocNode => typeof c !== 'string' && c.type === 'heading',
187
+ )
188
+ if (firstHeading) title = getTextContent(firstHeading.children)
189
+ }
190
+
191
+ const card = {
192
+ cardsV2: [
193
+ {
194
+ cardId: 'document',
195
+ card: {
196
+ header: title
197
+ ? { title, subtitle: (node.props.subject as string) ?? undefined }
198
+ : undefined,
199
+ sections: [
200
+ {
201
+ widgets,
202
+ },
203
+ ],
204
+ },
205
+ },
206
+ ],
207
+ }
208
+
209
+ return JSON.stringify(card, null, 2)
210
+ },
211
+ }
@@ -0,0 +1,225 @@
1
+ import type {
2
+ DocChild,
3
+ DocNode,
4
+ DocumentRenderer,
5
+ RenderOptions,
6
+ TableColumn,
7
+ } from '../types'
8
+
9
+ function escapeHtml(str: string): string {
10
+ return str
11
+ .replace(/&/g, '&amp;')
12
+ .replace(/</g, '&lt;')
13
+ .replace(/>/g, '&gt;')
14
+ .replace(/"/g, '&quot;')
15
+ }
16
+
17
+ function resolveColumn(col: string | TableColumn): TableColumn {
18
+ return typeof col === 'string' ? { header: col } : col
19
+ }
20
+
21
+ function styleStr(styles: Record<string, string | number | undefined>): string {
22
+ const parts: string[] = []
23
+ for (const [k, v] of Object.entries(styles)) {
24
+ if (v != null && v !== '') {
25
+ const prop = k.replace(/([A-Z])/g, '-$1').toLowerCase()
26
+ parts.push(`${prop}:${typeof v === 'number' ? `${v}px` : v}`)
27
+ }
28
+ }
29
+ return parts.length > 0 ? ` style="${parts.join(';')}"` : ''
30
+ }
31
+
32
+ function padStr(
33
+ pad: number | [number, number] | [number, number, number, number] | undefined,
34
+ ): string | undefined {
35
+ if (pad == null) return undefined
36
+ if (typeof pad === 'number') return `${pad}px`
37
+ if (pad.length === 2) return `${pad[0]}px ${pad[1]}px`
38
+ return `${pad[0]}px ${pad[1]}px ${pad[2]}px ${pad[3]}px`
39
+ }
40
+
41
+ function renderChild(child: DocChild): string {
42
+ if (typeof child === 'string') return escapeHtml(child)
43
+ return renderNode(child)
44
+ }
45
+
46
+ function renderChildren(children: DocChild[]): string {
47
+ return children.map(renderChild).join('')
48
+ }
49
+
50
+ function renderNode(node: DocNode): string {
51
+ const p = node.props
52
+
53
+ switch (node.type) {
54
+ case 'document': {
55
+ const lang = (p.language as string) ?? 'en'
56
+ const title = p.title
57
+ ? `<title>${escapeHtml(p.title as string)}</title>`
58
+ : ''
59
+ return `<!DOCTYPE html><html lang="${lang}"><head><meta charset="utf-8">${title}<meta name="viewport" content="width=device-width,initial-scale=1"></head><body>${renderChildren(node.children)}</body></html>`
60
+ }
61
+
62
+ case 'page': {
63
+ const margin = padStr(p.margin as PageMargin)
64
+ return `<div${styleStr({ maxWidth: '800px', margin: margin ?? '0 auto', padding: margin ?? '40px' })}>${renderChildren(node.children)}</div>`
65
+ }
66
+
67
+ case 'section': {
68
+ const dir = (p.direction as string) ?? 'column'
69
+ return `<div${styleStr({
70
+ display: dir === 'row' ? 'flex' : 'block',
71
+ flexDirection: dir === 'row' ? 'row' : undefined,
72
+ gap: p.gap as number | undefined,
73
+ padding: padStr(p.padding as PageMargin),
74
+ background: p.background as string | undefined,
75
+ borderRadius: p.borderRadius as number | undefined,
76
+ })}>${renderChildren(node.children)}</div>`
77
+ }
78
+
79
+ case 'row':
80
+ return `<div${styleStr({ display: 'flex', gap: p.gap as number | undefined, alignItems: p.align as string | undefined })}>${renderChildren(node.children)}</div>`
81
+
82
+ case 'column':
83
+ return `<div${styleStr({ flex: p.width ? undefined : '1', width: p.width as string | undefined, textAlign: p.align as string | undefined })}>${renderChildren(node.children)}</div>`
84
+
85
+ case 'heading': {
86
+ const level = (p.level as number) ?? 1
87
+ const tag = `h${Math.min(Math.max(level, 1), 6)}`
88
+ return `<${tag}${styleStr({ color: p.color as string | undefined, textAlign: p.align as string | undefined })}>${renderChildren(node.children)}</${tag}>`
89
+ }
90
+
91
+ case 'text': {
92
+ return `<p${styleStr({
93
+ fontSize: p.size as number | undefined,
94
+ color: p.color as string | undefined,
95
+ fontWeight: p.bold ? 'bold' : undefined,
96
+ fontStyle: p.italic ? 'italic' : undefined,
97
+ textDecoration: p.underline
98
+ ? 'underline'
99
+ : p.strikethrough
100
+ ? 'line-through'
101
+ : undefined,
102
+ textAlign: p.align as string | undefined,
103
+ lineHeight: p.lineHeight as number | undefined,
104
+ })}>${renderChildren(node.children)}</p>`
105
+ }
106
+
107
+ case 'link':
108
+ return `<a href="${escapeHtml(p.href as string)}"${styleStr({ color: p.color as string | undefined })}>${renderChildren(node.children)}</a>`
109
+
110
+ case 'image': {
111
+ const alignStyle =
112
+ p.align === 'center'
113
+ ? 'display:block;margin:0 auto'
114
+ : p.align === 'right'
115
+ ? 'display:block;margin-left:auto'
116
+ : ''
117
+ const img = `<img src="${escapeHtml(p.src as string)}"${p.width ? ` width="${p.width}"` : ''}${p.height ? ` height="${p.height}"` : ''}${p.alt ? ` alt="${escapeHtml(p.alt as string)}"` : ''}${alignStyle ? ` style="${alignStyle}"` : ''} />`
118
+ if (p.caption) {
119
+ return `<figure${p.align === 'center' ? ' style="text-align:center"' : ''}>${img}<figcaption>${escapeHtml(p.caption as string)}</figcaption></figure>`
120
+ }
121
+ return img
122
+ }
123
+
124
+ case 'table': {
125
+ const columns = ((p.columns ?? []) as (string | TableColumn)[]).map(
126
+ resolveColumn,
127
+ )
128
+ const rows = (p.rows ?? []) as (string | number)[][]
129
+ const hs = p.headerStyle as
130
+ | { background?: string; color?: string; bold?: boolean }
131
+ | undefined
132
+ const striped = p.striped as boolean | undefined
133
+ const bordered = p.bordered as boolean | undefined
134
+ const borderStyle = bordered
135
+ ? 'border:1px solid #ddd;border-collapse:collapse;'
136
+ : 'border-collapse:collapse;'
137
+
138
+ let html = `<table style="width:100%;${borderStyle}">`
139
+ if (p.caption)
140
+ html += `<caption>${escapeHtml(p.caption as string)}</caption>`
141
+
142
+ html += '<thead><tr>'
143
+ for (const col of columns) {
144
+ const cellBorder = bordered ? 'border:1px solid #ddd;' : ''
145
+ const bgStyle = hs?.background ? `background:${hs.background};` : ''
146
+ const colorStyle = hs?.color ? `color:${hs.color};` : ''
147
+ const fontStyle = hs?.bold !== false ? 'font-weight:bold;' : ''
148
+ const alignStyle = col.align ? `text-align:${col.align};` : ''
149
+ const widthStyle = col.width
150
+ ? `width:${typeof col.width === 'number' ? `${col.width}px` : col.width};`
151
+ : ''
152
+ html += `<th style="${cellBorder}${bgStyle}${colorStyle}${fontStyle}${alignStyle}${widthStyle}padding:8px">${escapeHtml(col.header)}</th>`
153
+ }
154
+ html += '</tr></thead>'
155
+
156
+ html += '<tbody>'
157
+ for (let i = 0; i < rows.length; i++) {
158
+ const rowBg =
159
+ striped && i % 2 === 1 ? ' style="background:#f9f9f9"' : ''
160
+ html += `<tr${rowBg}>`
161
+ for (let j = 0; j < columns.length; j++) {
162
+ const cellBorder = bordered ? 'border:1px solid #ddd;' : ''
163
+ const col = columns[j]
164
+ const alignStyle = col?.align ? `text-align:${col.align};` : ''
165
+ html += `<td style="${cellBorder}${alignStyle}padding:8px">${escapeHtml(String(rows[i]?.[j] ?? ''))}</td>`
166
+ }
167
+ html += '</tr>'
168
+ }
169
+ html += '</tbody></table>'
170
+ return html
171
+ }
172
+
173
+ case 'list': {
174
+ const tag = p.ordered ? 'ol' : 'ul'
175
+ return `<${tag}>${renderChildren(node.children)}</${tag}>`
176
+ }
177
+
178
+ case 'list-item':
179
+ return `<li>${renderChildren(node.children)}</li>`
180
+
181
+ case 'code':
182
+ return `<pre style="background:#f5f5f5;padding:12px;border-radius:4px;overflow-x:auto"><code>${escapeHtml(renderChildren(node.children))}</code></pre>`
183
+
184
+ case 'divider': {
185
+ const color = (p.color as string) ?? '#ddd'
186
+ const thickness = (p.thickness as number) ?? 1
187
+ return `<hr style="border:none;border-top:${thickness}px solid ${color};margin:16px 0" />`
188
+ }
189
+
190
+ case 'page-break':
191
+ return '<div style="page-break-after:always;break-after:page"></div>'
192
+
193
+ case 'spacer':
194
+ return `<div style="height:${p.height}px"></div>`
195
+
196
+ case 'button': {
197
+ const bg = (p.background as string) ?? '#4f46e5'
198
+ const color = (p.color as string) ?? '#fff'
199
+ const radius = (p.borderRadius as number) ?? 4
200
+ const pad = padStr((p.padding ?? [12, 24]) as [number, number])
201
+ const align = (p.align as string) ?? 'left'
202
+ return `<div style="text-align:${align}"><a href="${escapeHtml(p.href as string)}" style="display:inline-block;background:${bg};color:${color};padding:${pad};border-radius:${radius}px;text-decoration:none;font-weight:bold">${renderChildren(node.children)}</a></div>`
203
+ }
204
+
205
+ case 'quote': {
206
+ const borderColor = (p.borderColor as string) ?? '#ddd'
207
+ return `<blockquote style="margin:0;padding:12px 20px;border-left:4px solid ${borderColor};color:#555">${renderChildren(node.children)}</blockquote>`
208
+ }
209
+
210
+ default:
211
+ return renderChildren(node.children)
212
+ }
213
+ }
214
+
215
+ type PageMargin = number | [number, number] | [number, number, number, number]
216
+
217
+ export const htmlRenderer: DocumentRenderer = {
218
+ async render(node: DocNode, options?: RenderOptions): Promise<string> {
219
+ let html = renderNode(node)
220
+ if (options?.direction === 'rtl') {
221
+ html = html.replace('<body>', '<body dir="rtl" style="direction:rtl">')
222
+ }
223
+ return html
224
+ },
225
+ }