@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
package/src/render.ts ADDED
@@ -0,0 +1,222 @@
1
+ import type {
2
+ DocNode,
3
+ DocumentRenderer,
4
+ OutputFormat,
5
+ RenderOptions,
6
+ RenderResult,
7
+ } from './types'
8
+
9
+ // ─── Renderer Registry ──────────────────────────────────────────────────────
10
+
11
+ const renderers = new Map<
12
+ string,
13
+ DocumentRenderer | (() => Promise<DocumentRenderer>)
14
+ >()
15
+
16
+ /**
17
+ * Register a custom renderer for a format.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * registerRenderer('thermal', {
22
+ * render(node, options) {
23
+ * // Walk nodes → ESC/POS commands
24
+ * return escPosBuffer
25
+ * },
26
+ * })
27
+ *
28
+ * await render(receipt, 'thermal')
29
+ * ```
30
+ */
31
+ export function registerRenderer(
32
+ format: string,
33
+ renderer: DocumentRenderer | (() => Promise<DocumentRenderer>),
34
+ ): void {
35
+ renderers.set(format, renderer)
36
+ }
37
+
38
+ /**
39
+ * Remove a registered renderer.
40
+ */
41
+ export function unregisterRenderer(format: string): void {
42
+ renderers.delete(format)
43
+ }
44
+
45
+ // ─── Built-in Renderer Loaders ──────────────────────────────────────────────
46
+
47
+ // Built-in renderers are registered lazily — only loaded when first used.
48
+
49
+ registerRenderer('html', () =>
50
+ import('./renderers/html').then((m) => m.htmlRenderer),
51
+ )
52
+
53
+ registerRenderer('email', () =>
54
+ import('./renderers/email').then((m) => m.emailRenderer),
55
+ )
56
+
57
+ registerRenderer('md', () =>
58
+ import('./renderers/markdown').then((m) => m.markdownRenderer),
59
+ )
60
+
61
+ registerRenderer('text', () =>
62
+ import('./renderers/text').then((m) => m.textRenderer),
63
+ )
64
+
65
+ registerRenderer('csv', () =>
66
+ import('./renderers/csv').then((m) => m.csvRenderer),
67
+ )
68
+
69
+ registerRenderer('pdf', () =>
70
+ import('./renderers/pdf').then((m) => m.pdfRenderer),
71
+ )
72
+
73
+ registerRenderer('docx', () =>
74
+ import('./renderers/docx').then((m) => m.docxRenderer),
75
+ )
76
+
77
+ registerRenderer('xlsx', () =>
78
+ import('./renderers/xlsx').then((m) => m.xlsxRenderer),
79
+ )
80
+
81
+ registerRenderer('pptx', () =>
82
+ import('./renderers/pptx').then((m) => m.pptxRenderer),
83
+ )
84
+
85
+ registerRenderer('slack', () =>
86
+ import('./renderers/slack').then((m) => m.slackRenderer),
87
+ )
88
+
89
+ registerRenderer('svg', () =>
90
+ import('./renderers/svg').then((m) => m.svgRenderer),
91
+ )
92
+
93
+ registerRenderer('teams', () =>
94
+ import('./renderers/teams').then((m) => m.teamsRenderer),
95
+ )
96
+
97
+ registerRenderer('discord', () =>
98
+ import('./renderers/discord').then((m) => m.discordRenderer),
99
+ )
100
+
101
+ registerRenderer('telegram', () =>
102
+ import('./renderers/telegram').then((m) => m.telegramRenderer),
103
+ )
104
+
105
+ registerRenderer('notion', () =>
106
+ import('./renderers/notion').then((m) => m.notionRenderer),
107
+ )
108
+
109
+ registerRenderer('confluence', () =>
110
+ import('./renderers/confluence').then((m) => m.confluenceRenderer),
111
+ )
112
+
113
+ registerRenderer('whatsapp', () =>
114
+ import('./renderers/whatsapp').then((m) => m.whatsappRenderer),
115
+ )
116
+
117
+ registerRenderer('google-chat', () =>
118
+ import('./renderers/google-chat').then((m) => m.googleChatRenderer),
119
+ )
120
+
121
+ // ─── Render Function ────────────────────────────────────────────────────────
122
+
123
+ async function resolveRenderer(format: string): Promise<DocumentRenderer> {
124
+ const entry = renderers.get(format)
125
+ if (!entry) {
126
+ throw new Error(
127
+ `[@pyreon/document] No renderer registered for format '${format}'. Available: ${[...renderers.keys()].join(', ')}`,
128
+ )
129
+ }
130
+
131
+ if (typeof entry === 'function') {
132
+ const renderer = await entry()
133
+ // Cache the resolved renderer so we don't re-import
134
+ renderers.set(format, renderer)
135
+ return renderer
136
+ }
137
+
138
+ return entry
139
+ }
140
+
141
+ /**
142
+ * Render a document node tree to the specified format.
143
+ *
144
+ * @example
145
+ * ```tsx
146
+ * const doc = <Document title="Report"><Page>...</Page></Document>
147
+ *
148
+ * const html = await render(doc, 'html') // → HTML string
149
+ * const pdf = await render(doc, 'pdf') // → PDF Uint8Array
150
+ * const docx = await render(doc, 'docx') // → DOCX Uint8Array
151
+ * const email = await render(doc, 'email') // → email-safe HTML string
152
+ * const md = await render(doc, 'md') // → Markdown string
153
+ * ```
154
+ */
155
+ export async function render(
156
+ node: DocNode,
157
+ format: OutputFormat | string,
158
+ options?: RenderOptions,
159
+ ): Promise<RenderResult> {
160
+ const renderer = await resolveRenderer(format)
161
+ return renderer.render(node, options)
162
+ }
163
+
164
+ /** @internal For testing — reset renderer registry to defaults. */
165
+ export function _resetRenderers(): void {
166
+ renderers.clear()
167
+ // Re-register built-in lazy loaders
168
+ registerRenderer('html', () =>
169
+ import('./renderers/html').then((m) => m.htmlRenderer),
170
+ )
171
+ registerRenderer('email', () =>
172
+ import('./renderers/email').then((m) => m.emailRenderer),
173
+ )
174
+ registerRenderer('md', () =>
175
+ import('./renderers/markdown').then((m) => m.markdownRenderer),
176
+ )
177
+ registerRenderer('text', () =>
178
+ import('./renderers/text').then((m) => m.textRenderer),
179
+ )
180
+ registerRenderer('csv', () =>
181
+ import('./renderers/csv').then((m) => m.csvRenderer),
182
+ )
183
+ registerRenderer('pdf', () =>
184
+ import('./renderers/pdf').then((m) => m.pdfRenderer),
185
+ )
186
+ registerRenderer('docx', () =>
187
+ import('./renderers/docx').then((m) => m.docxRenderer),
188
+ )
189
+ registerRenderer('xlsx', () =>
190
+ import('./renderers/xlsx').then((m) => m.xlsxRenderer),
191
+ )
192
+ registerRenderer('pptx', () =>
193
+ import('./renderers/pptx').then((m) => m.pptxRenderer),
194
+ )
195
+ registerRenderer('slack', () =>
196
+ import('./renderers/slack').then((m) => m.slackRenderer),
197
+ )
198
+ registerRenderer('svg', () =>
199
+ import('./renderers/svg').then((m) => m.svgRenderer),
200
+ )
201
+ registerRenderer('teams', () =>
202
+ import('./renderers/teams').then((m) => m.teamsRenderer),
203
+ )
204
+ registerRenderer('discord', () =>
205
+ import('./renderers/discord').then((m) => m.discordRenderer),
206
+ )
207
+ registerRenderer('telegram', () =>
208
+ import('./renderers/telegram').then((m) => m.telegramRenderer),
209
+ )
210
+ registerRenderer('notion', () =>
211
+ import('./renderers/notion').then((m) => m.notionRenderer),
212
+ )
213
+ registerRenderer('confluence', () =>
214
+ import('./renderers/confluence').then((m) => m.confluenceRenderer),
215
+ )
216
+ registerRenderer('whatsapp', () =>
217
+ import('./renderers/whatsapp').then((m) => m.whatsappRenderer),
218
+ )
219
+ registerRenderer('google-chat', () =>
220
+ import('./renderers/google-chat').then((m) => m.googleChatRenderer),
221
+ )
222
+ }
@@ -0,0 +1,231 @@
1
+ import type {
2
+ DocChild,
3
+ DocNode,
4
+ DocumentRenderer,
5
+ RenderOptions,
6
+ TableColumn,
7
+ } from '../types'
8
+
9
+ /**
10
+ * Atlassian Document Format (ADF) renderer — for Jira and Confluence.
11
+ * ADF is the JSON format used by Atlassian's Document API.
12
+ * Can be posted to Confluence pages, Jira issue descriptions, and comments.
13
+ */
14
+
15
+ function resolveColumn(col: string | TableColumn): TableColumn {
16
+ return typeof col === 'string' ? { header: col } : col
17
+ }
18
+
19
+ function getTextContent(children: DocChild[]): string {
20
+ return children
21
+ .map((c) =>
22
+ typeof c === 'string' ? c : getTextContent((c as DocNode).children),
23
+ )
24
+ .join('')
25
+ }
26
+
27
+ interface AdfNode {
28
+ type: string
29
+ content?: AdfNode[]
30
+ text?: string
31
+ marks?: { type: string; attrs?: Record<string, unknown> }[]
32
+ attrs?: Record<string, unknown>
33
+ }
34
+
35
+ function textNode(text: string, marks?: AdfNode['marks']): AdfNode {
36
+ return { type: 'text', text, ...(marks && marks.length > 0 ? { marks } : {}) }
37
+ }
38
+
39
+ function nodeToAdf(node: DocNode): AdfNode[] {
40
+ const p = node.props
41
+ const result: AdfNode[] = []
42
+
43
+ switch (node.type) {
44
+ case 'document':
45
+ case 'page':
46
+ case 'section':
47
+ case 'row':
48
+ case 'column':
49
+ for (const child of node.children) {
50
+ if (typeof child !== 'string') {
51
+ result.push(...nodeToAdf(child))
52
+ }
53
+ }
54
+ break
55
+
56
+ case 'heading': {
57
+ const level = Math.min(Math.max((p.level as number) ?? 1, 1), 6)
58
+ const text = getTextContent(node.children)
59
+ result.push({
60
+ type: 'heading',
61
+ attrs: { level },
62
+ content: [textNode(text, [{ type: 'strong' }])],
63
+ })
64
+ break
65
+ }
66
+
67
+ case 'text': {
68
+ const text = getTextContent(node.children)
69
+ const marks: AdfNode['marks'] = []
70
+ if (p.bold) marks.push({ type: 'strong' })
71
+ if (p.italic) marks.push({ type: 'em' })
72
+ if (p.underline) marks.push({ type: 'underline' })
73
+ if (p.strikethrough) marks.push({ type: 'strike' })
74
+ if (p.color)
75
+ marks.push({ type: 'textColor', attrs: { color: p.color as string } })
76
+ result.push({
77
+ type: 'paragraph',
78
+ content: [textNode(text, marks)],
79
+ })
80
+ break
81
+ }
82
+
83
+ case 'link': {
84
+ const href = p.href as string
85
+ const text = getTextContent(node.children)
86
+ result.push({
87
+ type: 'paragraph',
88
+ content: [textNode(text, [{ type: 'link', attrs: { href } }])],
89
+ })
90
+ break
91
+ }
92
+
93
+ case 'image': {
94
+ const src = p.src as string
95
+ if (src.startsWith('http')) {
96
+ result.push({
97
+ type: 'mediaSingle',
98
+ attrs: { layout: 'center' },
99
+ content: [
100
+ {
101
+ type: 'media',
102
+ attrs: {
103
+ type: 'external',
104
+ url: src,
105
+ width: (p.width as number) ?? undefined,
106
+ height: (p.height as number) ?? undefined,
107
+ },
108
+ },
109
+ ],
110
+ })
111
+ }
112
+ break
113
+ }
114
+
115
+ case 'table': {
116
+ const columns = ((p.columns ?? []) as (string | TableColumn)[]).map(
117
+ resolveColumn,
118
+ )
119
+ const rows = (p.rows ?? []) as (string | number)[][]
120
+
121
+ const headerRow: AdfNode = {
122
+ type: 'tableRow',
123
+ content: columns.map((col) => ({
124
+ type: 'tableHeader',
125
+ content: [
126
+ {
127
+ type: 'paragraph',
128
+ content: [textNode(col.header, [{ type: 'strong' }])],
129
+ },
130
+ ],
131
+ })),
132
+ }
133
+
134
+ const dataRows = rows.map((row) => ({
135
+ type: 'tableRow' as const,
136
+ content: columns.map((_, i) => ({
137
+ type: 'tableCell' as const,
138
+ content: [
139
+ {
140
+ type: 'paragraph' as const,
141
+ content: [textNode(String(row[i] ?? ''))],
142
+ },
143
+ ],
144
+ })),
145
+ }))
146
+
147
+ result.push({
148
+ type: 'table',
149
+ attrs: { isNumberColumnEnabled: false, layout: 'default' },
150
+ content: [headerRow, ...dataRows],
151
+ })
152
+ break
153
+ }
154
+
155
+ case 'list': {
156
+ const ordered = p.ordered as boolean | undefined
157
+ const type = ordered ? 'orderedList' : 'bulletList'
158
+ const items = node.children
159
+ .filter((c): c is DocNode => typeof c !== 'string')
160
+ .map((item) => ({
161
+ type: 'listItem' as const,
162
+ content: [
163
+ {
164
+ type: 'paragraph' as const,
165
+ content: [textNode(getTextContent(item.children))],
166
+ },
167
+ ],
168
+ }))
169
+ result.push({ type, content: items })
170
+ break
171
+ }
172
+
173
+ case 'code': {
174
+ const text = getTextContent(node.children)
175
+ const lang = (p.language as string) ?? null
176
+ result.push({
177
+ type: 'codeBlock',
178
+ attrs: { language: lang },
179
+ content: [textNode(text)],
180
+ })
181
+ break
182
+ }
183
+
184
+ case 'divider':
185
+ case 'page-break':
186
+ result.push({ type: 'rule' })
187
+ break
188
+
189
+ case 'spacer':
190
+ result.push({ type: 'paragraph', content: [] })
191
+ break
192
+
193
+ case 'button': {
194
+ const href = p.href as string
195
+ const text = getTextContent(node.children)
196
+ result.push({
197
+ type: 'paragraph',
198
+ content: [
199
+ textNode(text, [
200
+ { type: 'link', attrs: { href } },
201
+ { type: 'strong' },
202
+ ]),
203
+ ],
204
+ })
205
+ break
206
+ }
207
+
208
+ case 'quote': {
209
+ const text = getTextContent(node.children)
210
+ result.push({
211
+ type: 'blockquote',
212
+ content: [{ type: 'paragraph', content: [textNode(text)] }],
213
+ })
214
+ break
215
+ }
216
+ }
217
+
218
+ return result
219
+ }
220
+
221
+ export const confluenceRenderer: DocumentRenderer = {
222
+ async render(node: DocNode, _options?: RenderOptions): Promise<string> {
223
+ const content = nodeToAdf(node)
224
+ const adf = {
225
+ version: 1,
226
+ type: 'doc',
227
+ content,
228
+ }
229
+ return JSON.stringify(adf, null, 2)
230
+ },
231
+ }
@@ -0,0 +1,67 @@
1
+ import type {
2
+ DocNode,
3
+ DocumentRenderer,
4
+ RenderOptions,
5
+ TableColumn,
6
+ } from '../types'
7
+
8
+ function resolveColumn(col: string | TableColumn): TableColumn {
9
+ return typeof col === 'string' ? { header: col } : col
10
+ }
11
+
12
+ function escapeCsv(value: string): string {
13
+ if (value.includes(',') || value.includes('"') || value.includes('\n')) {
14
+ return `"${value.replace(/"/g, '""')}"`
15
+ }
16
+ return value
17
+ }
18
+
19
+ function findTables(node: DocNode): DocNode[] {
20
+ const tables: DocNode[] = []
21
+ if (node.type === 'table') {
22
+ tables.push(node)
23
+ }
24
+ for (const child of node.children) {
25
+ if (typeof child !== 'string') {
26
+ tables.push(...findTables(child))
27
+ }
28
+ }
29
+ return tables
30
+ }
31
+
32
+ function tableToCsv(node: DocNode): string {
33
+ const columns = ((node.props.columns ?? []) as (string | TableColumn)[]).map(
34
+ resolveColumn,
35
+ )
36
+ const rows = (node.props.rows ?? []) as (string | number)[][]
37
+
38
+ const lines: string[] = []
39
+
40
+ // Caption as comment
41
+ if (node.props.caption) {
42
+ lines.push(`# ${node.props.caption}`)
43
+ }
44
+
45
+ // Header
46
+ lines.push(columns.map((c) => escapeCsv(c.header)).join(','))
47
+
48
+ // Rows
49
+ for (const row of rows) {
50
+ lines.push(row.map((cell) => escapeCsv(String(cell ?? ''))).join(','))
51
+ }
52
+
53
+ return lines.join('\n')
54
+ }
55
+
56
+ export const csvRenderer: DocumentRenderer = {
57
+ async render(node: DocNode, _options?: RenderOptions): Promise<string> {
58
+ const tables = findTables(node)
59
+
60
+ if (tables.length === 0) {
61
+ return '# No tables found in document\n'
62
+ }
63
+
64
+ // If multiple tables, separate with blank lines
65
+ return `${tables.map(tableToCsv).join('\n\n')}\n`
66
+ },
67
+ }
@@ -0,0 +1,192 @@
1
+ import type {
2
+ DocChild,
3
+ DocNode,
4
+ DocumentRenderer,
5
+ RenderOptions,
6
+ TableColumn,
7
+ } from '../types'
8
+
9
+ /**
10
+ * Discord renderer — outputs embed JSON for Discord webhooks/bots.
11
+ * Uses Discord's markdown subset and embed structure.
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 DiscordField {
27
+ name: string
28
+ value: string
29
+ inline?: boolean
30
+ }
31
+
32
+ /** Extract the first h1 title and first HTTP image from the tree. */
33
+ function extractMeta(node: DocNode): { title?: string; imageUrl?: string } {
34
+ if (node.type === 'heading') {
35
+ const level = (node.props.level as number) ?? 1
36
+ if (level === 1) return { title: getTextContent(node.children) }
37
+ }
38
+ if (node.type === 'image') {
39
+ const src = node.props.src as string
40
+ if (src.startsWith('http')) return { imageUrl: src }
41
+ }
42
+ for (const child of node.children) {
43
+ if (typeof child !== 'string') {
44
+ const result = extractMeta(child)
45
+ if (result.title || result.imageUrl) return result
46
+ }
47
+ }
48
+ return {}
49
+ }
50
+
51
+ function nodeToMarkdown(
52
+ node: DocNode,
53
+ meta: { title?: string },
54
+ ): { content: string; fields: DiscordField[] } {
55
+ const p = node.props
56
+ let content = ''
57
+ const fields: DiscordField[] = []
58
+
59
+ switch (node.type) {
60
+ case 'document':
61
+ case 'page':
62
+ case 'section':
63
+ case 'row':
64
+ case 'column':
65
+ for (const child of node.children) {
66
+ if (typeof child !== 'string') {
67
+ const result = nodeToMarkdown(child, meta)
68
+ content += result.content
69
+ fields.push(...result.fields)
70
+ }
71
+ }
72
+ break
73
+
74
+ case 'heading': {
75
+ const text = getTextContent(node.children)
76
+ const level = (p.level as number) ?? 1
77
+ // Skip the first h1 — it's used as embed title
78
+ if (level === 1 && text === meta.title) {
79
+ break
80
+ }
81
+ content += `**${text}**\n\n`
82
+ break
83
+ }
84
+
85
+ case 'text': {
86
+ let text = getTextContent(node.children)
87
+ if (p.bold) text = `**${text}**`
88
+ if (p.italic) text = `*${text}*`
89
+ if (p.strikethrough) text = `~~${text}~~`
90
+ content += `${text}\n\n`
91
+ break
92
+ }
93
+
94
+ case 'link': {
95
+ const href = p.href as string
96
+ const text = getTextContent(node.children)
97
+ content += `[${text}](${href})\n\n`
98
+ break
99
+ }
100
+
101
+ case 'image':
102
+ // Image handled via extractMeta — embedded as embed.image
103
+ break
104
+
105
+ case 'table': {
106
+ const columns = ((p.columns ?? []) as (string | TableColumn)[]).map(
107
+ resolveColumn,
108
+ )
109
+ const rows = (p.rows ?? []) as (string | number)[][]
110
+
111
+ // Use Discord embed fields for small tables
112
+ if (columns.length <= 3 && rows.length <= 10) {
113
+ for (const col of columns) {
114
+ const colIdx = columns.indexOf(col)
115
+ const values = rows.map((row) => String(row[colIdx] ?? '')).join('\n')
116
+ fields.push({
117
+ name: col.header,
118
+ value: values || '-',
119
+ inline: true,
120
+ })
121
+ }
122
+ } else {
123
+ // Fallback to code block for large tables
124
+ const header = columns.map((c) => c.header).join(' | ')
125
+ const separator = columns.map(() => '---').join(' | ')
126
+ const body = rows
127
+ .map((row) => row.map((c) => String(c ?? '')).join(' | '))
128
+ .join('\n')
129
+ content += `\`\`\`\n${header}\n${separator}\n${body}\n\`\`\`\n\n`
130
+ }
131
+ break
132
+ }
133
+
134
+ case 'list': {
135
+ const ordered = p.ordered as boolean | undefined
136
+ const items = node.children
137
+ .filter((c): c is DocNode => typeof c !== 'string')
138
+ .map((item, i) => {
139
+ const prefix = ordered ? `${i + 1}.` : '•'
140
+ return `${prefix} ${getTextContent(item.children)}`
141
+ })
142
+ .join('\n')
143
+ content += `${items}\n\n`
144
+ break
145
+ }
146
+
147
+ case 'code': {
148
+ const lang = (p.language as string) ?? ''
149
+ const text = getTextContent(node.children)
150
+ content += `\`\`\`${lang}\n${text}\n\`\`\`\n\n`
151
+ break
152
+ }
153
+
154
+ case 'divider':
155
+ case 'page-break':
156
+ content += '───────────\n\n'
157
+ break
158
+
159
+ case 'button': {
160
+ const href = p.href as string
161
+ const text = getTextContent(node.children)
162
+ content += `[**${text}**](${href})\n\n`
163
+ break
164
+ }
165
+
166
+ case 'quote': {
167
+ const text = getTextContent(node.children)
168
+ content += `> ${text}\n\n`
169
+ break
170
+ }
171
+ }
172
+
173
+ return { content, fields }
174
+ }
175
+
176
+ export const discordRenderer: DocumentRenderer = {
177
+ async render(node: DocNode, _options?: RenderOptions): Promise<string> {
178
+ const meta = extractMeta(node)
179
+ const { content, fields } = nodeToMarkdown(node, meta)
180
+
181
+ const embed: Record<string, unknown> = {
182
+ title: meta.title ?? (node.props.title as string) ?? undefined,
183
+ description: content.trim() || undefined,
184
+ color: 0x4f46e5,
185
+ }
186
+
187
+ if (fields.length > 0) embed.fields = fields
188
+ if (meta.imageUrl) embed.image = { url: meta.imageUrl }
189
+
190
+ return JSON.stringify({ embeds: [embed] }, null, 2)
191
+ },
192
+ }