@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.
- package/LICENSE +21 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/chunk-ErZ26oRB.js +48 -0
- package/lib/confluence-Va8e7RxQ.js +192 -0
- package/lib/confluence-Va8e7RxQ.js.map +1 -0
- package/lib/csv-2c38ub-Y.js +32 -0
- package/lib/csv-2c38ub-Y.js.map +1 -0
- package/lib/discord-DAoUZqvE.js +134 -0
- package/lib/discord-DAoUZqvE.js.map +1 -0
- package/lib/dist-BsqdI2nY.js +20179 -0
- package/lib/dist-BsqdI2nY.js.map +1 -0
- package/lib/docx-CorFwEH9.js +450 -0
- package/lib/docx-CorFwEH9.js.map +1 -0
- package/lib/email-Bn_Brjdp.js +131 -0
- package/lib/email-Bn_Brjdp.js.map +1 -0
- package/lib/exceljs-BoIDUUaw.js +34377 -0
- package/lib/exceljs-BoIDUUaw.js.map +1 -0
- package/lib/google-chat-B6I017I1.js +125 -0
- package/lib/google-chat-B6I017I1.js.map +1 -0
- package/lib/html-De_iS_f0.js +151 -0
- package/lib/html-De_iS_f0.js.map +1 -0
- package/lib/index.js +619 -0
- package/lib/index.js.map +1 -0
- package/lib/markdown-BYC_3C9i.js +75 -0
- package/lib/markdown-BYC_3C9i.js.map +1 -0
- package/lib/notion-DHaQHO6P.js +187 -0
- package/lib/notion-DHaQHO6P.js.map +1 -0
- package/lib/pdf-CDPc5Itc.js +419 -0
- package/lib/pdf-CDPc5Itc.js.map +1 -0
- package/lib/pdfmake-DnmLxK4Q.js +55511 -0
- package/lib/pdfmake-DnmLxK4Q.js.map +1 -0
- package/lib/pptx-DKQU6bjq.js +252 -0
- package/lib/pptx-DKQU6bjq.js.map +1 -0
- package/lib/pptxgen.es-COcgXsyx.js +5697 -0
- package/lib/pptxgen.es-COcgXsyx.js.map +1 -0
- package/lib/slack-CJRJgkag.js +139 -0
- package/lib/slack-CJRJgkag.js.map +1 -0
- package/lib/svg-BM8biZmL.js +187 -0
- package/lib/svg-BM8biZmL.js.map +1 -0
- package/lib/teams-S99tonRG.js +176 -0
- package/lib/teams-S99tonRG.js.map +1 -0
- package/lib/telegram-CbEO_2PN.js +77 -0
- package/lib/telegram-CbEO_2PN.js.map +1 -0
- package/lib/text-B5U8ucRr.js +75 -0
- package/lib/text-B5U8ucRr.js.map +1 -0
- package/lib/types/index.d.ts +528 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/vfs_fonts-Df1kkZ4Y.js +19 -0
- package/lib/vfs_fonts-Df1kkZ4Y.js.map +1 -0
- package/lib/whatsapp-DJ2D1jGG.js +64 -0
- package/lib/whatsapp-DJ2D1jGG.js.map +1 -0
- package/lib/xlsx-D47x-gZ5.js +199 -0
- package/lib/xlsx-D47x-gZ5.js.map +1 -0
- package/package.json +62 -0
- package/src/builder.ts +266 -0
- package/src/download.ts +76 -0
- package/src/env.d.ts +17 -0
- package/src/index.ts +98 -0
- package/src/nodes.ts +315 -0
- package/src/render.ts +222 -0
- package/src/renderers/confluence.ts +231 -0
- package/src/renderers/csv.ts +67 -0
- package/src/renderers/discord.ts +192 -0
- package/src/renderers/docx.ts +612 -0
- package/src/renderers/email.ts +230 -0
- package/src/renderers/google-chat.ts +211 -0
- package/src/renderers/html.ts +225 -0
- package/src/renderers/markdown.ts +144 -0
- package/src/renderers/notion.ts +264 -0
- package/src/renderers/pdf.ts +427 -0
- package/src/renderers/pptx.ts +353 -0
- package/src/renderers/slack.ts +192 -0
- package/src/renderers/svg.ts +254 -0
- package/src/renderers/teams.ts +234 -0
- package/src/renderers/telegram.ts +137 -0
- package/src/renderers/text.ts +154 -0
- package/src/renderers/whatsapp.ts +121 -0
- package/src/renderers/xlsx.ts +342 -0
- package/src/tests/document.test.ts +2920 -0
- 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
|
+
}
|