@knpkv/confluence-to-markdown 0.5.0 → 0.6.0

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 (108) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +45 -10
  3. package/dist/ConfluenceAuth.d.ts.map +1 -1
  4. package/dist/ConfluenceAuth.js +12 -22
  5. package/dist/ConfluenceAuth.js.map +1 -1
  6. package/dist/ConfluenceClient.d.ts +13 -3
  7. package/dist/ConfluenceClient.d.ts.map +1 -1
  8. package/dist/ConfluenceClient.js +34 -70
  9. package/dist/ConfluenceClient.js.map +1 -1
  10. package/dist/ConfluenceError.d.ts +12 -12
  11. package/dist/GitError.d.ts +5 -5
  12. package/dist/GitService.d.ts.map +1 -1
  13. package/dist/GitService.js +0 -3
  14. package/dist/GitService.js.map +1 -1
  15. package/dist/SchemaConverterError.d.ts +3 -3
  16. package/dist/ast/BlockNode.d.ts +48 -33
  17. package/dist/ast/BlockNode.d.ts.map +1 -1
  18. package/dist/ast/BlockNode.js +11 -2
  19. package/dist/ast/BlockNode.js.map +1 -1
  20. package/dist/ast/Document.d.ts +30 -2
  21. package/dist/ast/Document.d.ts.map +1 -1
  22. package/dist/parsers/ConfluenceParser.d.ts.map +1 -1
  23. package/dist/parsers/ConfluenceParser.js +7 -12
  24. package/dist/parsers/ConfluenceParser.js.map +1 -1
  25. package/dist/parsers/MarkdownParser.js +8 -117
  26. package/dist/parsers/MarkdownParser.js.map +1 -1
  27. package/dist/parsers/preprocessing/ConfluencePreprocessing.d.ts +23 -0
  28. package/dist/parsers/preprocessing/ConfluencePreprocessing.d.ts.map +1 -0
  29. package/dist/parsers/preprocessing/ConfluencePreprocessing.js +323 -0
  30. package/dist/parsers/preprocessing/ConfluencePreprocessing.js.map +1 -0
  31. package/dist/parsers/preprocessing/index.d.ts +7 -0
  32. package/dist/parsers/preprocessing/index.d.ts.map +1 -0
  33. package/dist/parsers/preprocessing/index.js +7 -0
  34. package/dist/parsers/preprocessing/index.js.map +1 -0
  35. package/dist/schemas/preprocessing/ConfluencePreprocessor.d.ts +29 -0
  36. package/dist/schemas/preprocessing/ConfluencePreprocessor.d.ts.map +1 -1
  37. package/dist/schemas/preprocessing/ConfluencePreprocessor.js +5 -15
  38. package/dist/schemas/preprocessing/ConfluencePreprocessor.js.map +1 -1
  39. package/dist/serializers/ConfluenceSerializer.js +0 -9
  40. package/dist/serializers/ConfluenceSerializer.js.map +1 -1
  41. package/dist/serializers/MarkdownSerializer.js +9 -49
  42. package/dist/serializers/MarkdownSerializer.js.map +1 -1
  43. package/package.json +35 -26
  44. package/src/AdfPlaceholders.ts +266 -0
  45. package/src/AdfSchemaValidator.ts +67 -0
  46. package/src/AdfWalker.ts +511 -0
  47. package/src/AtlaskitTransformers.ts +72 -0
  48. package/src/ConfluenceClient.ts +4 -4
  49. package/src/ConfluenceError.ts +65 -3
  50. package/src/MarkdownConverter.ts +106 -139
  51. package/src/Schemas.ts +4 -4
  52. package/src/SyncEngine.ts +130 -83
  53. package/src/atlaskit-adf-schema.d.ts +3 -0
  54. package/src/commands/clone.ts +8 -1
  55. package/src/commands/layers.ts +11 -4
  56. package/src/index.ts +3 -18
  57. package/test/AdfPlaceholders.test.ts +295 -0
  58. package/test/AdfSchemaValidator.test.ts +34 -0
  59. package/test/AdfWalker.test.ts +530 -0
  60. package/test/AtlaskitTransformers.test.ts +25 -0
  61. package/test/MarkdownConverter.test.ts +120 -105
  62. package/test/RoundTrip.test.ts +266 -0
  63. package/LICENSE +0 -21
  64. package/src/SchemaConverterError.ts +0 -108
  65. package/src/ast/BlockNode.ts +0 -469
  66. package/src/ast/Document.ts +0 -90
  67. package/src/ast/InlineNode.ts +0 -323
  68. package/src/ast/MacroNode.ts +0 -245
  69. package/src/ast/index.ts +0 -83
  70. package/src/parsers/ConfluenceParser.ts +0 -956
  71. package/src/parsers/MarkdownParser.ts +0 -1338
  72. package/src/parsers/index.ts +0 -8
  73. package/src/schemas/ConfluenceSchema.ts +0 -56
  74. package/src/schemas/ConversionSchema.ts +0 -318
  75. package/src/schemas/MarkdownSchema.ts +0 -56
  76. package/src/schemas/hast/HastFromHtml.ts +0 -153
  77. package/src/schemas/hast/HastSchema.ts +0 -274
  78. package/src/schemas/hast/index.ts +0 -35
  79. package/src/schemas/index.ts +0 -20
  80. package/src/schemas/mdast/MdastFromMarkdown.ts +0 -118
  81. package/src/schemas/mdast/MdastSchema.ts +0 -566
  82. package/src/schemas/mdast/index.ts +0 -59
  83. package/src/schemas/mdast/mdastToString.ts +0 -102
  84. package/src/schemas/nodes/block/BlockSchema.ts +0 -773
  85. package/src/schemas/nodes/block/index.ts +0 -13
  86. package/src/schemas/nodes/index.ts +0 -20
  87. package/src/schemas/nodes/inline/InlineSchema.ts +0 -523
  88. package/src/schemas/nodes/inline/index.ts +0 -14
  89. package/src/schemas/nodes/macro/MacroSchema.ts +0 -226
  90. package/src/schemas/nodes/macro/index.ts +0 -6
  91. package/src/schemas/preprocessing/ConfluencePreprocessor.ts +0 -455
  92. package/src/schemas/preprocessing/index.ts +0 -8
  93. package/src/serializers/ConfluenceSerializer.ts +0 -737
  94. package/src/serializers/MarkdownSerializer.ts +0 -543
  95. package/src/serializers/index.ts +0 -8
  96. package/test/ast/BlockNode.test.ts +0 -265
  97. package/test/ast/Document.test.ts +0 -126
  98. package/test/ast/InlineNode.test.ts +0 -161
  99. package/test/fixtures/integration-test.html.fixture +0 -103
  100. package/test/fixtures/integration-test.md.expected +0 -257
  101. package/test/parsers/ConfluenceParser.test.ts +0 -452
  102. package/test/schemas/ConfluencePreprocessor.test.ts +0 -180
  103. package/test/schemas/ConversionSchema.test.ts +0 -159
  104. package/test/schemas/HastSchema.test.ts +0 -138
  105. package/test/schemas/MdastSchema.test.ts +0 -145
  106. package/test/schemas/nodes/block/BlockSchema.test.ts +0 -173
  107. package/test/schemas/nodes/inline/InlineSchema.test.ts +0 -198
  108. package/test/schemas/nodes/macro/MacroSchema.test.ts +0 -142
@@ -1,737 +0,0 @@
1
- /**
2
- * Serializer for AST to Confluence storage format (HTML).
3
- *
4
- * @module
5
- */
6
- import * as Effect from "effect/Effect"
7
- import type { CodeBlock, Heading, Image, Paragraph, Table, ThematicBreak, UnsupportedBlock } from "../ast/BlockNode.js"
8
- import type { Document, DocumentNode } from "../ast/Document.js"
9
- import type { InlineNode } from "../ast/InlineNode.js"
10
- import type { SerializeError } from "../SchemaConverterError.js"
11
-
12
- /**
13
- * Serialize Document AST to Confluence storage format HTML.
14
- *
15
- * @example
16
- * ```typescript
17
- * import { serializeToConfluence } from "@knpkv/confluence-to-markdown/serializers/ConfluenceSerializer"
18
- * import { makeDocument, Heading, Text } from "@knpkv/confluence-to-markdown/ast"
19
- * import { Effect } from "effect"
20
- *
21
- * Effect.gen(function* () {
22
- * const doc = makeDocument([
23
- * new Heading({ level: 1, children: [new Text({ value: "Title" })] })
24
- * ])
25
- * const html = yield* serializeToConfluence(doc)
26
- * console.log(html) // <h1>Title</h1>
27
- * })
28
- * ```
29
- *
30
- * @category Serializers
31
- */
32
- export const serializeToConfluence = (doc: Document): Effect.Effect<string, SerializeError> =>
33
- Effect.gen(function*() {
34
- // 1-to-1 roundtrip: if rawConfluence is available, return it as-is
35
- if (doc.rawConfluence !== undefined) {
36
- return doc.rawConfluence
37
- }
38
-
39
- const parts: Array<string> = []
40
- for (const node of doc.children) {
41
- const serialized = yield* serializeDocumentNode(node)
42
- parts.push(serialized)
43
- }
44
- const raw = parts.join("\n")
45
- // Post-process to reconstruct layouts from markers
46
- return reconstructLayouts(raw)
47
- })
48
-
49
- /**
50
- * Reconstruct layouts from marker comments.
51
- *
52
- * Markers:
53
- * - <!--cf:layout-start-->
54
- * - <!--cf:section:index;type;breakoutMode;breakoutWidth;cellCount-->
55
- * - <!--cf:cell:sectionIndex;cellIndex-->
56
- * - <!--cf:section-end:index-->
57
- * - <!--cf:layout-end-->
58
- */
59
- const reconstructLayouts = (html: string): string => {
60
- // Check if there are any layout markers
61
- if (!html.includes("<!--cf:layout-start-->")) {
62
- return html
63
- }
64
-
65
- let result = html
66
-
67
- // Process each layout block
68
- const layoutRegex = /<!--cf:layout-start-->([\s\S]*?)<!--cf:layout-end-->/g
69
- result = result.replace(layoutRegex, (_, layoutContent: string) => {
70
- // Parse sections from the content
71
- const sections: Array<{
72
- type: string
73
- breakoutMode: string
74
- breakoutWidth: string
75
- cells: Array<string>
76
- }> = []
77
-
78
- // Find all section markers
79
- const sectionRegex = /<!--cf:section:(\d+);([^;]*);([^;]*);([^;]*);(\d+)-->/g
80
- let sectionMatch
81
- const sectionMeta: Array<
82
- { index: number; type: string; breakoutMode: string; breakoutWidth: string; cellCount: number }
83
- > = []
84
-
85
- while ((sectionMatch = sectionRegex.exec(layoutContent)) !== null) {
86
- sectionMeta.push({
87
- index: parseInt(sectionMatch[1] ?? "0"),
88
- type: decodeURIComponent(sectionMatch[2] ?? "fixed-width"),
89
- breakoutMode: decodeURIComponent(sectionMatch[3] ?? ""),
90
- breakoutWidth: decodeURIComponent(sectionMatch[4] ?? ""),
91
- cellCount: parseInt(sectionMatch[5] ?? "0")
92
- })
93
- }
94
-
95
- // For each section, extract cell content
96
- for (const meta of sectionMeta) {
97
- const cells: Array<string> = []
98
-
99
- for (let cellIndex = 0; cellIndex < meta.cellCount; cellIndex++) {
100
- const cellStartMarker = `<!--cf:cell:${meta.index};${cellIndex}-->`
101
- const nextCellMarker = `<!--cf:cell:${meta.index};${cellIndex + 1}-->`
102
- const sectionEndMarker = `<!--cf:section-end:${meta.index}-->`
103
- const nextSectionMarker = `<!--cf:section:${meta.index + 1};`
104
-
105
- const cellStart = layoutContent.indexOf(cellStartMarker)
106
- if (cellStart === -1) continue
107
-
108
- const contentStart = cellStart + cellStartMarker.length
109
-
110
- // Find where this cell ends - either next cell, section end, or next section
111
- let cellEnd = layoutContent.length
112
- const nextCell = layoutContent.indexOf(nextCellMarker, contentStart)
113
- const secEnd = layoutContent.indexOf(sectionEndMarker, contentStart)
114
- const nextSec = layoutContent.indexOf(nextSectionMarker, contentStart)
115
-
116
- if (nextCell !== -1 && nextCell < cellEnd) cellEnd = nextCell
117
- if (secEnd !== -1 && secEnd < cellEnd) cellEnd = secEnd
118
- if (nextSec !== -1 && nextSec < cellEnd) cellEnd = nextSec
119
-
120
- const cellContent = layoutContent.slice(contentStart, cellEnd).trim()
121
- cells.push(cellContent)
122
- }
123
-
124
- sections.push({
125
- type: meta.type,
126
- breakoutMode: meta.breakoutMode,
127
- breakoutWidth: meta.breakoutWidth,
128
- cells
129
- })
130
- }
131
-
132
- // Build the layout HTML
133
- const sectionHtml = sections.map((section) => {
134
- const typeAttr = ` ac:type="${escapeHtml(section.type)}"`
135
- const breakoutModeAttr = section.breakoutMode ? ` ac:breakout-mode="${escapeHtml(section.breakoutMode)}"` : ""
136
- const breakoutWidthAttr = section.breakoutWidth ? ` ac:breakout-width="${escapeHtml(section.breakoutWidth)}"` : ""
137
- const cellsHtml = section.cells.map((c) => `<ac:layout-cell>${c}</ac:layout-cell>`).join("")
138
- return `<ac:layout-section${typeAttr}${breakoutModeAttr}${breakoutWidthAttr}>${cellsHtml}</ac:layout-section>`
139
- }).join("")
140
-
141
- return `<ac:layout>${sectionHtml}</ac:layout>`
142
- })
143
-
144
- return result
145
- }
146
-
147
- /**
148
- * Serialize a document node to Confluence HTML.
149
- */
150
- const serializeDocumentNode = (node: DocumentNode): Effect.Effect<string, SerializeError> =>
151
- Effect.gen(function*() {
152
- switch (node._tag) {
153
- // Block nodes
154
- case "Heading":
155
- return yield* serializeHeading({ level: node.level, children: node.children })
156
- case "Paragraph":
157
- return yield* serializeParagraph({
158
- children: node.children,
159
- alignment: node.alignment,
160
- indent: node.indent
161
- })
162
- case "CodeBlock":
163
- return serializeCodeBlock({ code: node.code, language: node.language })
164
- case "ThematicBreak":
165
- return "<hr/>"
166
- case "Image":
167
- return serializeImage({
168
- src: node.src,
169
- attachment: node.attachment,
170
- alt: node.alt,
171
- title: node.title,
172
- align: node.align,
173
- width: node.width
174
- })
175
- case "Table":
176
- return yield* serializeTable({ header: node.header, rows: node.rows })
177
- case "List":
178
- return yield* serializeList({
179
- ordered: node.ordered,
180
- start: node.start,
181
- children: node.children as unknown as Array<ListItemType>
182
- })
183
- case "BlockQuote":
184
- return yield* serializeBlockQuote({ children: node.children as unknown as Array<SimpleBlock> })
185
- case "UnsupportedBlock": {
186
- const raw = node.rawHtml || node.rawMarkdown || ""
187
- // Check for comment-encoded decision list
188
- const decisionMatch = raw.match(/<!--cf:decision:(.*)-->/)
189
- if (decisionMatch) {
190
- const itemsStr = decisionMatch[1] ?? ""
191
- const items = itemsStr.split("|").map((item) => {
192
- const parts = item.split(";")
193
- return {
194
- localId: decodeURIComponent(parts[0] ?? ""),
195
- state: decodeURIComponent(parts[1] ?? ""),
196
- content: decodeURIComponent(parts[2] ?? "")
197
- }
198
- })
199
- const decisionItems = items.map((item) =>
200
- `<ac:adf-node type="decision-item"><ac:adf-attribute key="local-id">${
201
- escapeHtml(item.localId)
202
- }</ac:adf-attribute><ac:adf-attribute key="state">${
203
- escapeHtml(item.state)
204
- }</ac:adf-attribute><ac:adf-content>${escapeHtml(item.content)}</ac:adf-content></ac:adf-node>`
205
- ).join("")
206
- const fallbackItems = items.map((item) => `<li>${escapeHtml(item.content)}</li>`).join("")
207
- return `<ac:adf-extension><ac:adf-node type="decision-list">${decisionItems}</ac:adf-node><ac:adf-fallback><ul class="decision-list">${fallbackItems}</ul></ac:adf-fallback></ac:adf-extension>`
208
- }
209
- // Layout markers are passed through - reconstructLayouts will process them
210
- return raw
211
- }
212
-
213
- // Macro nodes - serialize to Confluence macros
214
- case "InfoPanel":
215
- return yield* serializeInfoPanel({
216
- panelType: node.panelType,
217
- title: node.title,
218
- children: node.children as unknown as Array<SimpleBlock>
219
- })
220
- case "ExpandMacro":
221
- return yield* serializeExpandMacro({
222
- title: node.title,
223
- children: node.children as unknown as Array<SimpleBlock>
224
- })
225
- case "TocMacro":
226
- return serializeTocMacro({ minLevel: node.minLevel, maxLevel: node.maxLevel })
227
- case "CodeMacro":
228
- return serializeCodeMacro({
229
- language: node.language,
230
- title: node.title,
231
- code: node.code,
232
- lineNumbers: node.lineNumbers,
233
- collapse: node.collapse,
234
- firstLine: node.firstLine
235
- })
236
- case "StatusMacro":
237
- return serializeStatusMacro({ text: node.text, color: node.color })
238
- case "TaskList":
239
- return yield* serializeTaskList(node.children)
240
-
241
- default:
242
- return ""
243
- }
244
- })
245
-
246
- /**
247
- * Serialize heading.
248
- */
249
- const serializeHeading = (
250
- node: { level: 1 | 2 | 3 | 4 | 5 | 6; children: ReadonlyArray<InlineNode> }
251
- ): Effect.Effect<string, SerializeError> =>
252
- Effect.gen(function*() {
253
- const content = yield* serializeInlineNodes(node.children)
254
- return `<h${node.level}>${content}</h${node.level}>`
255
- })
256
-
257
- /**
258
- * Serialize paragraph (with optional alignment and indent).
259
- */
260
- const serializeParagraph = (
261
- node: {
262
- children: ReadonlyArray<InlineNode>
263
- alignment?: "left" | "center" | "right" | undefined
264
- indent?: number | undefined
265
- }
266
- ): Effect.Effect<string, SerializeError> =>
267
- Effect.gen(function*() {
268
- const content = yield* serializeInlineNodes(node.children)
269
- const styles: Array<string> = []
270
- if (node.alignment) {
271
- styles.push(`text-align: ${node.alignment};`)
272
- }
273
- if (node.indent) {
274
- styles.push(`margin-left: ${node.indent}px;`)
275
- }
276
- const styleAttr = styles.length > 0 ? ` style="${styles.join(" ")}"` : ""
277
- return `<p${styleAttr}>${content}</p>`
278
- })
279
-
280
- /**
281
- * Serialize code block as Confluence code macro.
282
- */
283
- const serializeCodeBlock = (node: { code: string; language?: string | undefined }): string => {
284
- const lang = node.language ? `<ac:parameter ac:name="language">${escapeHtml(node.language)}</ac:parameter>` : ""
285
- return `<ac:structured-macro ac:name="code">${lang}<ac:plain-text-body><![CDATA[${node.code}]]></ac:plain-text-body></ac:structured-macro>`
286
- }
287
-
288
- /**
289
- * Serialize image (supports both URL and Confluence attachments).
290
- */
291
- const serializeImage = (node: {
292
- src?: string | undefined
293
- attachment?: { filename: string; version?: number | undefined } | undefined
294
- alt?: string | undefined
295
- title?: string | undefined
296
- align?: string | undefined
297
- width?: number | undefined
298
- }): string => {
299
- // Confluence attachment
300
- if (node.attachment) {
301
- const alignAttr = node.align ? ` ac:align="${node.align}"` : ""
302
- const widthAttr = node.width ? ` ac:width="${node.width}"` : ""
303
- const altAttr = node.alt ? ` ac:alt="${escapeHtml(node.alt)}"` : ""
304
- const versionAttr = node.attachment.version ? ` ri:version-at-save="${node.attachment.version}"` : ""
305
- return `<ac:image${alignAttr}${widthAttr}${altAttr}><ri:attachment ri:filename="${
306
- escapeHtml(node.attachment.filename)
307
- }"${versionAttr}/></ac:image>`
308
- }
309
-
310
- // URL-based image
311
- const src = node.src ?? ""
312
- const alt = node.alt ? ` alt="${escapeHtml(node.alt)}"` : ""
313
- const title = node.title ? ` title="${escapeHtml(node.title)}"` : ""
314
- return `<img src="${escapeHtml(src)}"${alt}${title}/>`
315
- }
316
-
317
- /**
318
- * Serialize table.
319
- */
320
- const serializeTable = (
321
- node: {
322
- header?:
323
- | { cells: ReadonlyArray<{ isHeader?: boolean | undefined; children: ReadonlyArray<InlineNode> }> }
324
- | undefined
325
- rows: ReadonlyArray<
326
- { cells: ReadonlyArray<{ isHeader?: boolean | undefined; children: ReadonlyArray<InlineNode> }> }
327
- >
328
- }
329
- ): Effect.Effect<string, SerializeError> =>
330
- Effect.gen(function*() {
331
- const parts: Array<string> = ["<table>"]
332
-
333
- // Header
334
- if (node.header) {
335
- parts.push("<thead><tr>")
336
- for (const cell of node.header.cells) {
337
- const content = yield* serializeInlineNodes(cell.children)
338
- parts.push(`<th>${content}</th>`)
339
- }
340
- parts.push("</tr></thead>")
341
- }
342
-
343
- // Body
344
- if (node.rows.length > 0) {
345
- parts.push("<tbody>")
346
- for (const row of node.rows) {
347
- parts.push("<tr>")
348
- for (const cell of row.cells) {
349
- const tag = cell.isHeader ? "th" : "td"
350
- const content = yield* serializeInlineNodes(cell.children)
351
- parts.push(`<${tag}>${content}</${tag}>`)
352
- }
353
- parts.push("</tr>")
354
- }
355
- parts.push("</tbody>")
356
- }
357
-
358
- parts.push("</table>")
359
- return parts.join("")
360
- })
361
-
362
- // Simple block type for list items (allows nested Lists for sub-bullets).
363
- type SimpleBlock =
364
- | Heading
365
- | Paragraph
366
- | CodeBlock
367
- | ThematicBreak
368
- | Image
369
- | Table
370
- | UnsupportedBlock
371
- | NestedList
372
-
373
- // Structural shape of a nested list inside a list item.
374
- type NestedList = {
375
- readonly _tag: "List"
376
- readonly ordered: boolean
377
- readonly start?: number | undefined
378
- readonly children: ReadonlyArray<ListItemType>
379
- }
380
-
381
- // List item type
382
- type ListItemType = {
383
- readonly _tag: "ListItem"
384
- readonly checked?: boolean | undefined
385
- readonly children: ReadonlyArray<SimpleBlock>
386
- }
387
-
388
- /**
389
- * Serialize list.
390
- */
391
- const serializeList = (
392
- node: { ordered: boolean; start?: number | undefined; children: ReadonlyArray<ListItemType> }
393
- ): Effect.Effect<string, SerializeError> =>
394
- Effect.gen(function*() {
395
- const tag = node.ordered ? "ol" : "ul"
396
- const startAttr = node.ordered && node.start && node.start !== 1 ? ` start="${node.start}"` : ""
397
- const parts: Array<string> = [`<${tag}${startAttr}>`]
398
-
399
- for (const item of node.children) {
400
- parts.push("<li>")
401
- if (item.checked !== undefined) {
402
- const checked = item.checked ? " checked" : ""
403
- parts.push(`<input type="checkbox"${checked}/>`)
404
- }
405
- for (const child of item.children) {
406
- parts.push(yield* serializeSimpleBlock(child))
407
- }
408
- parts.push("</li>")
409
- }
410
-
411
- parts.push(`</${tag}>`)
412
- return parts.join("")
413
- })
414
-
415
- /**
416
- * Serialize simple block.
417
- */
418
- const serializeSimpleBlock = (node: SimpleBlock): Effect.Effect<string, SerializeError> =>
419
- Effect.gen(function*() {
420
- switch (node._tag) {
421
- case "Heading":
422
- return yield* serializeHeading(
423
- node as unknown as { level: 1 | 2 | 3 | 4 | 5 | 6; children: ReadonlyArray<InlineNode> }
424
- )
425
- case "Paragraph":
426
- return yield* serializeParagraph(node as unknown as { children: ReadonlyArray<InlineNode> })
427
- case "CodeBlock":
428
- return serializeCodeBlock(node as unknown as { code: string; language?: string | undefined })
429
- case "ThematicBreak":
430
- return "<hr/>"
431
- case "Image":
432
- return serializeImage(node as unknown as { src: string; alt?: string | undefined; title?: string | undefined })
433
- case "Table":
434
- return yield* serializeTable(
435
- node as unknown as {
436
- header?:
437
- | { cells: ReadonlyArray<{ isHeader?: boolean | undefined; children: ReadonlyArray<InlineNode> }> }
438
- | undefined
439
- rows: ReadonlyArray<
440
- { cells: ReadonlyArray<{ isHeader?: boolean | undefined; children: ReadonlyArray<InlineNode> }> }
441
- >
442
- }
443
- )
444
- case "UnsupportedBlock": {
445
- const unsupported = node as unknown as { rawHtml?: string; rawMarkdown?: string }
446
- return unsupported.rawHtml || unsupported.rawMarkdown || ""
447
- }
448
- case "List":
449
- return yield* serializeList(node)
450
- default:
451
- return ""
452
- }
453
- })
454
-
455
- /**
456
- * Serialize block quote.
457
- */
458
- const serializeBlockQuote = (
459
- node: { children: ReadonlyArray<SimpleBlock> }
460
- ): Effect.Effect<string, SerializeError> =>
461
- Effect.gen(function*() {
462
- const parts: Array<string> = ["<blockquote>"]
463
- for (const child of node.children) {
464
- parts.push(yield* serializeSimpleBlock(child as SimpleBlock))
465
- }
466
- parts.push("</blockquote>")
467
- return parts.join("")
468
- })
469
-
470
- /**
471
- * Serialize info panel as Confluence macro.
472
- */
473
- const serializeInfoPanel = (
474
- node: { panelType: string; title?: string | undefined; children: ReadonlyArray<SimpleBlock> }
475
- ): Effect.Effect<string, SerializeError> =>
476
- Effect.gen(function*() {
477
- const titleParam = node.title
478
- ? `<ac:parameter ac:name="title">${escapeHtml(node.title)}</ac:parameter>`
479
- : ""
480
-
481
- const parts: Array<string> = [
482
- `<ac:structured-macro ac:name="${node.panelType}">`,
483
- titleParam,
484
- "<ac:rich-text-body>"
485
- ]
486
-
487
- for (const child of node.children) {
488
- parts.push(yield* serializeSimpleBlock(child as SimpleBlock))
489
- }
490
-
491
- parts.push("</ac:rich-text-body>")
492
- parts.push("</ac:structured-macro>")
493
- return parts.join("")
494
- })
495
-
496
- /**
497
- * Serialize expand macro as Confluence macro.
498
- */
499
- const serializeExpandMacro = (
500
- node: { title?: string | undefined; children: ReadonlyArray<SimpleBlock> }
501
- ): Effect.Effect<string, SerializeError> =>
502
- Effect.gen(function*() {
503
- const titleParam = node.title
504
- ? `<ac:parameter ac:name="title">${escapeHtml(node.title)}</ac:parameter>`
505
- : ""
506
-
507
- const parts: Array<string> = [
508
- `<ac:structured-macro ac:name="expand">`,
509
- titleParam,
510
- "<ac:rich-text-body>"
511
- ]
512
-
513
- for (const child of node.children) {
514
- parts.push(yield* serializeSimpleBlock(child as SimpleBlock))
515
- }
516
-
517
- parts.push("</ac:rich-text-body>")
518
- parts.push("</ac:structured-macro>")
519
- return parts.join("")
520
- })
521
-
522
- /**
523
- * Serialize TOC macro.
524
- */
525
- const serializeTocMacro = (node: { minLevel?: number | undefined; maxLevel?: number | undefined }): string => {
526
- const params: Array<string> = []
527
- if (node.minLevel) {
528
- params.push(`<ac:parameter ac:name="minLevel">${node.minLevel}</ac:parameter>`)
529
- }
530
- if (node.maxLevel) {
531
- params.push(`<ac:parameter ac:name="maxLevel">${node.maxLevel}</ac:parameter>`)
532
- }
533
- return `<ac:structured-macro ac:name="toc">${params.join("")}</ac:structured-macro>`
534
- }
535
-
536
- /**
537
- * Serialize code macro with full options.
538
- */
539
- const serializeCodeMacro = (
540
- node: {
541
- language?: string | undefined
542
- title?: string | undefined
543
- code: string
544
- lineNumbers?: boolean | undefined
545
- collapse?: boolean | undefined
546
- firstLine?: number | undefined
547
- }
548
- ): string => {
549
- const params: Array<string> = []
550
- if (node.language) {
551
- params.push(`<ac:parameter ac:name="language">${escapeHtml(node.language)}</ac:parameter>`)
552
- }
553
- if (node.title) {
554
- params.push(`<ac:parameter ac:name="title">${escapeHtml(node.title)}</ac:parameter>`)
555
- }
556
- if (node.lineNumbers) {
557
- params.push(`<ac:parameter ac:name="linenumbers">true</ac:parameter>`)
558
- }
559
- if (node.collapse) {
560
- params.push(`<ac:parameter ac:name="collapse">true</ac:parameter>`)
561
- }
562
- if (node.firstLine) {
563
- params.push(`<ac:parameter ac:name="firstline">${node.firstLine}</ac:parameter>`)
564
- }
565
-
566
- return `<ac:structured-macro ac:name="code">${
567
- params.join("")
568
- }<ac:plain-text-body><![CDATA[${node.code}]]></ac:plain-text-body></ac:structured-macro>`
569
- }
570
-
571
- /**
572
- * Serialize status macro.
573
- */
574
- const serializeStatusMacro = (node: { text: string; color: string }): string => {
575
- return `<ac:structured-macro ac:name="status"><ac:parameter ac:name="colour">${
576
- escapeHtml(node.color)
577
- }</ac:parameter><ac:parameter ac:name="title">${escapeHtml(node.text)}</ac:parameter></ac:structured-macro>`
578
- }
579
-
580
- /**
581
- * Serialize task list to Confluence storage format.
582
- */
583
- const serializeTaskList = (
584
- children: ReadonlyArray<{
585
- _tag: "TaskItem"
586
- id: string
587
- uuid: string
588
- status: "incomplete" | "complete"
589
- body: ReadonlyArray<InlineNode>
590
- }>
591
- ): Effect.Effect<string, SerializeError> =>
592
- Effect.gen(function*() {
593
- const parts: Array<string> = [`<ac:task-list>`]
594
-
595
- for (const item of children) {
596
- const body = yield* serializeInlineNodes(item.body)
597
- parts.push(
598
- `<ac:task>` +
599
- `<ac:task-id>${item.id}</ac:task-id>` +
600
- `<ac:task-uuid>${item.uuid}</ac:task-uuid>` +
601
- `<ac:task-status>${item.status}</ac:task-status>` +
602
- `<ac:task-body><span class="placeholder-inline-tasks">${body}</span></ac:task-body>` +
603
- `</ac:task>`
604
- )
605
- }
606
-
607
- parts.push(`</ac:task-list>`)
608
- return parts.join("\n")
609
- })
610
-
611
- /**
612
- * Serialize inline nodes to HTML.
613
- */
614
- const serializeInlineNodes = (
615
- nodes: ReadonlyArray<InlineNode>
616
- ): Effect.Effect<string, SerializeError> =>
617
- Effect.gen(function*() {
618
- const parts: Array<string> = []
619
- for (const node of nodes) {
620
- parts.push(yield* serializeInlineNode(node))
621
- }
622
- return parts.join("")
623
- })
624
-
625
- /**
626
- * Serialize inline node to HTML.
627
- */
628
- const serializeInlineNode = (node: InlineNode): Effect.Effect<string, SerializeError> =>
629
- Effect.gen(function*() {
630
- switch (node._tag) {
631
- case "Text":
632
- return escapeHtml(node.value)
633
- case "Strong": {
634
- const content = yield* serializeInlineNodes(node.children)
635
- return `<strong>${content}</strong>`
636
- }
637
- case "Emphasis": {
638
- const content = yield* serializeInlineNodes(node.children)
639
- return `<em>${content}</em>`
640
- }
641
- case "Underline": {
642
- const content = yield* serializeInlineNodes(node.children)
643
- return `<u>${content}</u>`
644
- }
645
- case "Strikethrough": {
646
- const content = yield* serializeInlineNodes(node.children)
647
- return `<del>${content}</del>`
648
- }
649
- case "Subscript": {
650
- const content = yield* serializeInlineNodes(node.children)
651
- return `<sub>${content}</sub>`
652
- }
653
- case "Superscript": {
654
- const content = yield* serializeInlineNodes(node.children)
655
- return `<sup>${content}</sup>`
656
- }
657
- case "InlineCode":
658
- return `<code>${escapeHtml(node.value)}</code>`
659
- case "Link": {
660
- // Round-trip view-file macro: a Link with href="attachment:FILENAME"
661
- // came from a Confluence ac:structured-macro name="view-file".
662
- const attachmentMatch = node.href.match(/^attachment:(.+)$/)
663
- if (attachmentMatch) {
664
- const filename = attachmentMatch[1] ?? ""
665
- return `<ac:structured-macro ac:name="view-file"><ac:parameter ac:name="name"><ri:attachment ri:filename="${
666
- escapeHtml(filename)
667
- }"/></ac:parameter></ac:structured-macro>`
668
- }
669
- const content = yield* serializeInlineNodes(node.children)
670
- const title = node.title ? ` title="${escapeHtml(node.title)}"` : ""
671
- return `<a href="${escapeHtml(node.href)}"${title}>${content}</a>`
672
- }
673
- case "LineBreak":
674
- return "<br/>"
675
- case "Emoticon":
676
- return `<ac:emoticon ac:emoji-shortname="${escapeHtml(node.shortname)}" ac:emoji-id="${
677
- escapeHtml(node.emojiId)
678
- }" ac:emoji-fallback="${escapeHtml(node.fallback)}"/>`
679
- case "UserMention":
680
- return `<ac:link><ri:user ri:account-id="${escapeHtml(node.accountId)}"/></ac:link>`
681
- case "DateTime":
682
- return `<time datetime="${escapeHtml(node.datetime)}"/>`
683
- case "ColoredText": {
684
- const content = yield* serializeInlineNodes(node.children)
685
- return `<span style="color: ${escapeHtml(node.color)};">${content}</span>`
686
- }
687
- case "Highlight": {
688
- const content = yield* serializeInlineNodes(node.children)
689
- return `<span style="background-color: ${escapeHtml(node.backgroundColor)};">${content}</span>`
690
- }
691
- case "UnsupportedInline": {
692
- // Check for comment-encoded TOC and convert back to Confluence macro
693
- const tocMatch = node.raw.match(/<!--cf:toc:([^;]*);([^;]*)-->/)
694
- if (tocMatch) {
695
- const minLevel = tocMatch[1]
696
- const maxLevel = tocMatch[2]
697
- let params = ""
698
- if (minLevel) params += `<ac:parameter ac:name="minLevel">${minLevel}</ac:parameter>`
699
- if (maxLevel) params += `<ac:parameter ac:name="maxLevel">${maxLevel}</ac:parameter>`
700
- return `<ac:structured-macro ac:name="toc">${params}</ac:structured-macro>`
701
- }
702
- // Check for comment-encoded Status macro
703
- const statusMatch = node.raw.match(/<!--cf:status:([^;]*);([^;]*)-->/)
704
- if (statusMatch) {
705
- const title = decodeURIComponent(statusMatch[1] ?? "")
706
- const color = decodeURIComponent(statusMatch[2] ?? "")
707
- let params = ""
708
- if (title) params += `<ac:parameter ac:name="title">${escapeHtml(title)}</ac:parameter>`
709
- if (color) params += `<ac:parameter ac:name="colour">${escapeHtml(color)}</ac:parameter>`
710
- return `<ac:structured-macro ac:name="status">${params}</ac:structured-macro>`
711
- }
712
- // Check for comment-encoded Smart link (Jira, etc.)
713
- const smartLinkMatch = node.raw.match(/<!--cf:smartlink:([^;]*);([^;]*);(.*)-->/)
714
- if (smartLinkMatch) {
715
- const href = decodeURIComponent(smartLinkMatch[1] ?? "")
716
- const appearance = decodeURIComponent(smartLinkMatch[2] ?? "")
717
- const datasource = decodeURIComponent(smartLinkMatch[3] ?? "")
718
- return `<a href="${escapeHtml(href)}" data-card-appearance="${escapeHtml(appearance)}" data-datasource="${
719
- escapeHtml(datasource)
720
- }">${escapeHtml(href)}</a>`
721
- }
722
- return node.raw
723
- }
724
- default:
725
- return ""
726
- }
727
- })
728
-
729
- /**
730
- * Escape HTML special characters.
731
- */
732
- const escapeHtml = (str: string): string =>
733
- str
734
- .replace(/&/g, "&amp;")
735
- .replace(/</g, "&lt;")
736
- .replace(/>/g, "&gt;")
737
- .replace(/"/g, "&quot;")