@knpkv/confluence-to-markdown 0.4.2 → 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 (93) hide show
  1. package/CHANGELOG.md +35 -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/parsers/preprocessing/ConfluencePreprocessing.d.ts +23 -0
  17. package/dist/parsers/preprocessing/ConfluencePreprocessing.d.ts.map +1 -0
  18. package/dist/parsers/preprocessing/ConfluencePreprocessing.js +323 -0
  19. package/dist/parsers/preprocessing/ConfluencePreprocessing.js.map +1 -0
  20. package/dist/parsers/preprocessing/index.d.ts +7 -0
  21. package/dist/parsers/preprocessing/index.d.ts.map +1 -0
  22. package/dist/parsers/preprocessing/index.js +7 -0
  23. package/dist/parsers/preprocessing/index.js.map +1 -0
  24. package/dist/schemas/preprocessing/ConfluencePreprocessor.d.ts +29 -0
  25. package/dist/schemas/preprocessing/ConfluencePreprocessor.d.ts.map +1 -1
  26. package/dist/schemas/preprocessing/ConfluencePreprocessor.js +3 -5
  27. package/dist/schemas/preprocessing/ConfluencePreprocessor.js.map +1 -1
  28. package/package.json +35 -26
  29. package/src/AdfPlaceholders.ts +266 -0
  30. package/src/AdfSchemaValidator.ts +67 -0
  31. package/src/AdfWalker.ts +511 -0
  32. package/src/AtlaskitTransformers.ts +72 -0
  33. package/src/ConfluenceClient.ts +4 -4
  34. package/src/ConfluenceError.ts +65 -3
  35. package/src/MarkdownConverter.ts +106 -139
  36. package/src/Schemas.ts +4 -4
  37. package/src/SyncEngine.ts +130 -83
  38. package/src/atlaskit-adf-schema.d.ts +3 -0
  39. package/src/commands/clone.ts +8 -1
  40. package/src/commands/layers.ts +11 -4
  41. package/src/index.ts +3 -18
  42. package/test/AdfPlaceholders.test.ts +295 -0
  43. package/test/AdfSchemaValidator.test.ts +34 -0
  44. package/test/AdfWalker.test.ts +530 -0
  45. package/test/AtlaskitTransformers.test.ts +25 -0
  46. package/test/MarkdownConverter.test.ts +120 -105
  47. package/test/RoundTrip.test.ts +266 -0
  48. package/LICENSE +0 -21
  49. package/src/SchemaConverterError.ts +0 -108
  50. package/src/ast/BlockNode.ts +0 -425
  51. package/src/ast/Document.ts +0 -90
  52. package/src/ast/InlineNode.ts +0 -323
  53. package/src/ast/MacroNode.ts +0 -245
  54. package/src/ast/index.ts +0 -83
  55. package/src/parsers/ConfluenceParser.ts +0 -950
  56. package/src/parsers/MarkdownParser.ts +0 -1198
  57. package/src/parsers/index.ts +0 -8
  58. package/src/schemas/ConfluenceSchema.ts +0 -56
  59. package/src/schemas/ConversionSchema.ts +0 -318
  60. package/src/schemas/MarkdownSchema.ts +0 -56
  61. package/src/schemas/hast/HastFromHtml.ts +0 -153
  62. package/src/schemas/hast/HastSchema.ts +0 -274
  63. package/src/schemas/hast/index.ts +0 -35
  64. package/src/schemas/index.ts +0 -20
  65. package/src/schemas/mdast/MdastFromMarkdown.ts +0 -118
  66. package/src/schemas/mdast/MdastSchema.ts +0 -566
  67. package/src/schemas/mdast/index.ts +0 -59
  68. package/src/schemas/mdast/mdastToString.ts +0 -102
  69. package/src/schemas/nodes/block/BlockSchema.ts +0 -773
  70. package/src/schemas/nodes/block/index.ts +0 -13
  71. package/src/schemas/nodes/index.ts +0 -20
  72. package/src/schemas/nodes/inline/InlineSchema.ts +0 -523
  73. package/src/schemas/nodes/inline/index.ts +0 -14
  74. package/src/schemas/nodes/macro/MacroSchema.ts +0 -226
  75. package/src/schemas/nodes/macro/index.ts +0 -6
  76. package/src/schemas/preprocessing/ConfluencePreprocessor.ts +0 -446
  77. package/src/schemas/preprocessing/index.ts +0 -8
  78. package/src/serializers/ConfluenceSerializer.ts +0 -717
  79. package/src/serializers/MarkdownSerializer.ts +0 -493
  80. package/src/serializers/index.ts +0 -8
  81. package/test/ast/BlockNode.test.ts +0 -265
  82. package/test/ast/Document.test.ts +0 -126
  83. package/test/ast/InlineNode.test.ts +0 -161
  84. package/test/fixtures/integration-test.html.fixture +0 -103
  85. package/test/fixtures/integration-test.md.expected +0 -257
  86. package/test/parsers/ConfluenceParser.test.ts +0 -283
  87. package/test/schemas/ConfluencePreprocessor.test.ts +0 -180
  88. package/test/schemas/ConversionSchema.test.ts +0 -159
  89. package/test/schemas/HastSchema.test.ts +0 -138
  90. package/test/schemas/MdastSchema.test.ts +0 -145
  91. package/test/schemas/nodes/block/BlockSchema.test.ts +0 -173
  92. package/test/schemas/nodes/inline/InlineSchema.test.ts +0 -198
  93. package/test/schemas/nodes/macro/MacroSchema.test.ts +0 -142
@@ -0,0 +1,511 @@
1
+ /**
2
+ * Owned ADF → Markdown tree walker.
3
+ *
4
+ * Pure recursive descent over an ADF document's `node.type` discriminant.
5
+ * Returns GFM markdown plus a list of warnings for lossy or unknown nodes.
6
+ *
7
+ * @module
8
+ */
9
+ import type { DocNode } from "@atlaskit/adf-schema"
10
+
11
+ /**
12
+ * Warning emitted by the walker. Surfaced via `Effect.logWarning` at the
13
+ * facade boundary; never escalated to errors so a single weird node cannot
14
+ * break a clone of many pages.
15
+ */
16
+ export type WalkerWarning =
17
+ | { readonly _tag: "UnsupportedNode"; readonly nodeType: string }
18
+ | { readonly _tag: "LossyMark"; readonly mark: string }
19
+ | { readonly _tag: "MediaWithoutUrl"; readonly mediaId: string }
20
+ | {
21
+ readonly _tag: "UnsupportedExtension"
22
+ readonly nodeType: "extension" | "bodiedExtension" | "inlineExtension"
23
+ readonly extensionKey: string
24
+ readonly extensionType: string
25
+ }
26
+
27
+ export interface WalkResult {
28
+ readonly markdown: string
29
+ readonly warnings: ReadonlyArray<WalkerWarning>
30
+ }
31
+
32
+ interface AdfNode {
33
+ readonly type: string
34
+ readonly attrs?: Record<string, unknown>
35
+ readonly content?: ReadonlyArray<AdfNode>
36
+ readonly text?: string
37
+ readonly marks?: ReadonlyArray<AdfNode>
38
+ }
39
+
40
+ interface Ctx {
41
+ readonly inTable: boolean
42
+ readonly warnings: Array<WalkerWarning>
43
+ }
44
+
45
+ // Mid-line characters that change inline parsing in GFM. We deliberately omit
46
+ // `.` and `-` because they only carry meaning at line-start (numbered lists,
47
+ // setext rules) and escaping them mid-line produces noisy output like
48
+ // `v1\.0\.0` for ordinary version strings. Same reasoning drops `#`, `+`, `>`
49
+ // (line-start only), `(`/`)` (only meaningful right after `]`, which we
50
+ // escape), `!` (only meaningful right before `[`, ditto) and `{`/`}` (not
51
+ // special in GFM at all) — escaping those produced noise like `\(v2\)`.
52
+ const ESCAPE_RE = /[\\`*_[\]<|]/g
53
+ const escapeText = (s: string): string => s.replace(ESCAPE_RE, "\\$&")
54
+ const escapeAttr = (s: string): string => s.replace(/[\\"]/g, "\\$&")
55
+ // For text inside HTML *blocks* (`<details>`/`<summary>`): CommonMark treats
56
+ // everything up to the closing blank line as raw HTML, so backslash escapes
57
+ // would render literally — entity-escape instead.
58
+ const escapeHtml = (s: string): string =>
59
+ s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
60
+ // `(`/`)`/space/`<`/`>`/`\` in a destination break `[text](url)` — wrap in
61
+ // angle brackets, percent-encoding the characters that would terminate or
62
+ // escape the wrapper itself.
63
+ const safeHref = (href: string): string =>
64
+ /[() <>\\]/.test(href) ? `<${href.replace(/[<>\\]/g, (c) => encodeURIComponent(c))}>` : href
65
+ // Alt text is substituted rather than escaped: @atlaskit's media markdown
66
+ // plugin throws on `\[`/`\]` in alt (making the page un-pushable), and
67
+ // newlines split the construct outright.
68
+ const sanitizeAlt = (s: string): string =>
69
+ s.replace(/\[/g, "(").replace(/\]/g, ")").replace(/\\/g, "/").replace(/\s+/g, " ").trim()
70
+
71
+ // ESCAPE_RE deliberately skips characters that are only special at line
72
+ // start, so lines assembled from paragraph text (including after hardBreak)
73
+ // must neutralize leading block markers: ATX headings, blockquotes, list
74
+ // bullets, ordered-list markers, thematic breaks, and setext underlines.
75
+ // A superfluous escape is harmless when the line later lands mid-line
76
+ // (after a list marker etc.) — backslash before punctuation always renders
77
+ // as the bare character.
78
+ const escapeLineStart = (line: string): string => {
79
+ if (/^(#{1,6}|[-+])(\s|$)/.test(line) || line.startsWith(">") || /^-{3,}\s*$/.test(line) || /^=+\s*$/.test(line)) {
80
+ return "\\" + line
81
+ }
82
+ const ordered = /^(\d+)([.)])(\s|$)/.exec(line)?.[1]
83
+ if (ordered) return `${ordered}\\${line.slice(ordered.length)}`
84
+ return line
85
+ }
86
+ const escapeLineStarts = (s: string): string => s.split("\n").map(escapeLineStart).join("\n")
87
+
88
+ const attrStr = (n: AdfNode, key: string): string | undefined => {
89
+ const v = n.attrs?.[key]
90
+ return typeof v === "string" ? v : undefined
91
+ }
92
+ const attrNum = (n: AdfNode, key: string): number | undefined => {
93
+ const v = n.attrs?.[key]
94
+ return typeof v === "number" ? v : undefined
95
+ }
96
+
97
+ // Color-matched to GitHub's admonition palette: info/blue→NOTE, note/purple→
98
+ // IMPORTANT, success/green→TIP, warning/yellow→WARNING, error/red→CAUTION.
99
+ const PANEL_MAP: Record<string, string> = {
100
+ info: "NOTE",
101
+ note: "IMPORTANT",
102
+ warning: "WARNING",
103
+ success: "TIP",
104
+ error: "CAUTION"
105
+ }
106
+
107
+ // btoa operates on byte strings; route through TextEncoder so non-ASCII attrs
108
+ // survive. Web APIs only — this module is a standalone subpath export and
109
+ // must not assume Node (same reasoning as internal/hashUtils' Web Crypto).
110
+ const toBase64 = (s: string): string => {
111
+ const bytes = new TextEncoder().encode(s)
112
+ let bin = ""
113
+ for (const b of bytes) bin += String.fromCharCode(b)
114
+ return btoa(bin)
115
+ }
116
+
117
+ // Deterministic JSON for the placeholder attrs blob: object keys are sorted
118
+ // recursively so the same attrs always produce the same base64, no matter
119
+ // what order Confluence happens to serialize them in. Keeps pull → push →
120
+ // pull a byte-level fixed point (and contentHash stable).
121
+ const stableStringify = (v: unknown): string => {
122
+ if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`
123
+ if (v !== null && typeof v === "object") {
124
+ const entries = Object.entries(v as Record<string, unknown>)
125
+ .filter(([, value]) => value !== undefined)
126
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
127
+ .map(([k, value]) => `${JSON.stringify(k)}:${stableStringify(value)}`)
128
+ return `{${entries.join(",")}}`
129
+ }
130
+ return JSON.stringify(v) ?? "null"
131
+ }
132
+
133
+ const inline = (nodes: ReadonlyArray<AdfNode> | undefined, ctx: Ctx): string => {
134
+ if (!nodes) return ""
135
+ let out = ""
136
+ for (const n of nodes) out += inlineNode(n, ctx)
137
+ return out
138
+ }
139
+
140
+ const inlineNode = (n: AdfNode, ctx: Ctx): string => {
141
+ switch (n.type) {
142
+ case "text": {
143
+ const marks = n.marks ?? []
144
+ const text = n.text ?? ""
145
+ // Code spans render their content literally — backslash-escaping inside
146
+ // backticks would emit literal backslashes, which the push side then
147
+ // preserves verbatim, doubling them on every pull/push round-trip.
148
+ const hasCode = marks.some((m) => m.type === "code")
149
+ return applyMarks(hasCode ? text : escapeText(text), marks, ctx)
150
+ }
151
+ case "hardBreak":
152
+ return ctx.inTable ? "<br>" : " \n"
153
+ case "mention": {
154
+ // Confluence stores the mention's `text` attr with the leading `@`
155
+ // already (e.g. "@John Doe"). Strip it so we don't emit `@@John Doe`.
156
+ const id = attrStr(n, "id")
157
+ const raw = attrStr(n, "text") ?? id ?? ""
158
+ const stripped = raw.startsWith("@") ? raw.slice(1) : raw
159
+ const display = `@${escapeText(stripped)}`
160
+ // Encode the accountId in a custom-scheme link so the push side can
161
+ // reconstruct a real mention node. Without `id` we can only emit plain
162
+ // text; on push, that becomes plain text in Confluence (lossy).
163
+ return id ? `[${display}](confluence-mention://${encodeURIComponent(id)})` : display
164
+ }
165
+ case "emoji": {
166
+ const short = attrStr(n, "shortName")
167
+ return short ? `:${short}:` : (attrStr(n, "text") ?? "")
168
+ }
169
+ case "inlineCard": {
170
+ const url = attrStr(n, "url")
171
+ if (!url) {
172
+ // data-payload smart links have no URL to render — losing one must
173
+ // at least be visible in the logs.
174
+ ctx.warnings.push({ _tag: "UnsupportedNode", nodeType: "inlineCard" })
175
+ return ""
176
+ }
177
+ return `<${url}>`
178
+ }
179
+ case "date": {
180
+ const ts = attrStr(n, "timestamp")
181
+ if (!ts) return ""
182
+ const d = new Date(Number(ts))
183
+ return Number.isNaN(d.getTime()) ? ts : d.toISOString().slice(0, 10)
184
+ }
185
+ case "status": {
186
+ const text = attrStr(n, "text") ?? ""
187
+ const color = attrStr(n, "color") ?? "neutral"
188
+ return `<span class="adf-status" data-color="${color}">${escapeText(text)}</span>`
189
+ }
190
+ case "mediaInline": {
191
+ const id = attrStr(n, "id") ?? ""
192
+ ctx.warnings.push({ _tag: "MediaWithoutUrl", mediaId: id })
193
+ return `<!-- adf:media id=${id} -->`
194
+ }
195
+ case "inlineExtension":
196
+ return extensionPlaceholder(n, "inlineExtension", ctx)
197
+ default:
198
+ ctx.warnings.push({ _tag: "UnsupportedNode", nodeType: n.type })
199
+ return `<!-- unsupported ADF inline: ${n.type} -->`
200
+ }
201
+ }
202
+
203
+ const extensionPlaceholder = (
204
+ n: AdfNode,
205
+ nodeType: "extension" | "bodiedExtension" | "inlineExtension",
206
+ ctx: Ctx
207
+ ): string => {
208
+ const extensionKey = attrStr(n, "extensionKey") ?? ""
209
+ const extensionType = attrStr(n, "extensionType") ?? ""
210
+ ctx.warnings.push({ _tag: "UnsupportedExtension", nodeType, extensionKey, extensionType })
211
+ const keyPart = extensionKey ? ` key=${extensionKey}` : ""
212
+ const typePart = extensionType ? ` type=${extensionType}` : ""
213
+ // key/type are repeated for human readability; `attrs` is the source of
214
+ // truth on push — it carries the *full* attrs (parameters, localId, layout)
215
+ // so macros survive a pull → push round-trip with their configuration.
216
+ const attrs = n.attrs ?? {}
217
+ const attrsPart = Object.keys(attrs).length > 0
218
+ ? ` attrs=${toBase64(stableStringify(attrs))}`
219
+ : ""
220
+ return `<!-- adf:${nodeType}${keyPart}${typePart}${attrsPart} -->`
221
+ }
222
+
223
+ const bodiedExtension = (n: AdfNode, ctx: Ctx): string => {
224
+ const open = extensionPlaceholder(n, "bodiedExtension", ctx)
225
+ // Table cells flatten newlines to <br>, which would weld the markers and
226
+ // body into one un-revertible line — emit only the single-line marker
227
+ // there (body dropped; the placeholder warning above covers it).
228
+ if (ctx.inTable) return open
229
+ // Render the body so it stays visible/editable; the end marker lets the
230
+ // push side re-attach everything in between as the bodiedExtension's body.
231
+ // It is emitted even for an empty body so the push side can tell "bodied
232
+ // macro with nothing in it" apart from a legacy/corrupted open marker.
233
+ const body = (n.content ?? []).map((c) => block(c, ctx)).join("\n\n")
234
+ const parts = body.length > 0 ? [open, body] : [open]
235
+ return [...parts, "<!-- adf:/bodiedExtension -->"].join("\n\n")
236
+ }
237
+
238
+ const applyMarks = (text: string, marks: ReadonlyArray<AdfNode>, ctx: Ctx): string => {
239
+ let out = text
240
+ for (const m of marks) {
241
+ switch (m.type) {
242
+ case "code": {
243
+ // Code-span content is unescaped, so per GFM the delimiter must be a
244
+ // backtick run longer than any run inside, space-padded when the
245
+ // content starts/ends with a backtick (or is empty).
246
+ const runs = out.match(/`+/g) ?? []
247
+ const fence = "`".repeat(runs.reduce((max, r) => Math.max(max, r.length), 0) + 1)
248
+ const pad = out === "" || out.startsWith("`") || out.endsWith("`") ? " " : ""
249
+ out = `${fence}${pad}${out}${pad}${fence}`
250
+ break
251
+ }
252
+ case "strong":
253
+ out = `**${out}**`
254
+ break
255
+ case "em":
256
+ out = `_${out}_`
257
+ break
258
+ case "strike":
259
+ out = `~~${out}~~`
260
+ break
261
+ case "link": {
262
+ const href = attrStr(m, "href") ?? ""
263
+ const title = attrStr(m, "title")
264
+ const titlePart = title ? ` "${escapeAttr(title)}"` : ""
265
+ out = `[${out}](${safeHref(href)}${titlePart})`
266
+ break
267
+ }
268
+ case "underline":
269
+ ctx.warnings.push({ _tag: "LossyMark", mark: "underline" })
270
+ out = `<u>${out}</u>`
271
+ break
272
+ case "textColor": {
273
+ const color = attrStr(m, "color") ?? ""
274
+ ctx.warnings.push({ _tag: "LossyMark", mark: "textColor" })
275
+ out = `<span style="color:${color}">${out}</span>`
276
+ break
277
+ }
278
+ case "backgroundColor": {
279
+ const color = attrStr(m, "color") ?? ""
280
+ ctx.warnings.push({ _tag: "LossyMark", mark: "backgroundColor" })
281
+ out = `<span style="background-color:${color}">${out}</span>`
282
+ break
283
+ }
284
+ case "subsup": {
285
+ const t = attrStr(m, "type") === "sup" ? "sup" : "sub"
286
+ out = `<${t}>${out}</${t}>`
287
+ break
288
+ }
289
+ default:
290
+ ctx.warnings.push({ _tag: "LossyMark", mark: m.type })
291
+ }
292
+ }
293
+ return out
294
+ }
295
+
296
+ const indentLines = (s: string, indent: string): string =>
297
+ s.split("\n").map((line, i) => (i === 0 ? line : indent + line)).join("\n")
298
+
299
+ const block = (n: AdfNode, ctx: Ctx): string => {
300
+ switch (n.type) {
301
+ case "paragraph":
302
+ return escapeLineStarts(inline(n.content, ctx))
303
+ case "heading": {
304
+ const level = Math.min(6, Math.max(1, attrNum(n, "level") ?? 1))
305
+ return "#".repeat(level) + " " + inline(n.content, ctx)
306
+ }
307
+ case "rule":
308
+ return "---"
309
+ case "blockquote":
310
+ return blockquote(n.content, ctx)
311
+ case "codeBlock":
312
+ return codeBlock(n)
313
+ case "bulletList":
314
+ return list(n, ctx, false)
315
+ case "orderedList":
316
+ return list(n, ctx, true)
317
+ case "table":
318
+ return table(n, ctx)
319
+ case "panel":
320
+ return panel(n, ctx)
321
+ case "expand":
322
+ case "nestedExpand":
323
+ return expand(n, ctx)
324
+ case "taskList":
325
+ return taskList(n, ctx)
326
+ case "decisionList":
327
+ return decisionList(n, ctx)
328
+ case "mediaSingle":
329
+ return mediaSingle(n, ctx)
330
+ case "mediaGroup":
331
+ return mediaGroup(n, ctx)
332
+ case "extension":
333
+ return extensionPlaceholder(n, "extension", ctx)
334
+ case "bodiedExtension":
335
+ return bodiedExtension(n, ctx)
336
+ default:
337
+ ctx.warnings.push({ _tag: "UnsupportedNode", nodeType: n.type })
338
+ return `<!-- unsupported ADF node: ${n.type} -->`
339
+ }
340
+ }
341
+
342
+ const blockquote = (content: ReadonlyArray<AdfNode> | undefined, ctx: Ctx): string => {
343
+ const inner = (content ?? []).map((c) => block(c, ctx)).join("\n\n")
344
+ return inner.split("\n").map((l) => (l.length === 0 ? ">" : `> ${l}`)).join("\n")
345
+ }
346
+
347
+ const codeBlock = (n: AdfNode): string => {
348
+ // A fence's info string may not contain backticks (CommonMark) and a
349
+ // newline would inject lines into the code content — the editor UI uses a
350
+ // fixed language list, but the REST API accepts arbitrary strings.
351
+ const lang = (attrStr(n, "language") ?? "").replace(/[`\s]+/g, "")
352
+ const text = (n.content ?? []).map((c) => c.text ?? "").join("")
353
+ // A fixed ``` fence would be terminated early by code that itself contains
354
+ // a triple-backtick run — use one backtick more than the longest run inside.
355
+ const runs = text.match(/`+/g) ?? []
356
+ const fence = "`".repeat(Math.max(3, runs.reduce((max, r) => Math.max(max, r.length), 0) + 1))
357
+ return fence + lang + "\n" + text + "\n" + fence
358
+ }
359
+
360
+ const listItemBlocks = (item: AdfNode, ctx: Ctx): string => {
361
+ const blocks = item.content ?? []
362
+ const parts: Array<string> = []
363
+ for (const b of blocks) {
364
+ if (b.type === "paragraph") {
365
+ // Continuation lines (after hardBreak) sit at line start once the
366
+ // item is indented, so they need the same leading-marker escapes as
367
+ // top-level paragraphs.
368
+ parts.push(escapeLineStarts(inline(b.content, ctx)))
369
+ } else {
370
+ parts.push(block(b, ctx))
371
+ }
372
+ }
373
+ return parts.join("\n\n")
374
+ }
375
+
376
+ const list = (n: AdfNode, ctx: Ctx, ordered: boolean): string => {
377
+ const items = n.content ?? []
378
+ const startNum = ordered ? Math.max(1, attrNum(n, "order") ?? 1) : 1
379
+ const indent = " "
380
+ const inner: Array<string> = []
381
+ for (let i = 0; i < items.length; i++) {
382
+ const item = items[i]
383
+ if (!item) continue
384
+ const marker = ordered ? `${startNum + i}. ` : "- "
385
+ const body = listItemBlocks(item, ctx)
386
+ inner.push(marker + indentLines(body, indent))
387
+ }
388
+ return inner.join("\n")
389
+ }
390
+
391
+ const tableCellInline = (cell: AdfNode, ctx: Ctx): string => {
392
+ const cellCtx: Ctx = { ...ctx, inTable: true }
393
+ const blocks = cell.content ?? []
394
+ const parts: Array<string> = []
395
+ for (const b of blocks) {
396
+ if (b.type === "paragraph") parts.push(inline(b.content, cellCtx))
397
+ else parts.push(block(b, cellCtx).replace(/\n/g, "<br>"))
398
+ }
399
+ // Escape `|` so it can't open a new column — but only pipes that aren't
400
+ // already escaped (inline() escapes them in plain text; code spans, URLs
401
+ // and <br>-flattened blocks don't). A pipe is escaped iff it's preceded by
402
+ // an odd run of backslashes, so count the run rather than peek one char.
403
+ return parts.join("<br>").replace(
404
+ /(\\*)\|/g,
405
+ (match, backslashes: string) => backslashes.length % 2 === 0 ? `${backslashes}\\|` : match
406
+ )
407
+ }
408
+
409
+ const table = (n: AdfNode, ctx: Ctx): string => {
410
+ const rows = n.content ?? []
411
+ if (rows.length === 0) return ""
412
+ const renderRow = (row: AdfNode): Array<string> => (row.content ?? []).map((cell) => tableCellInline(cell, ctx))
413
+ const allRows = rows.map(renderRow)
414
+ const colCount = Math.max(...allRows.map((r) => r.length))
415
+ const pad = (cells: Array<string>): Array<string> => {
416
+ const out = cells.slice()
417
+ while (out.length < colCount) out.push("")
418
+ return out
419
+ }
420
+ const firstRow = rows[0]
421
+ const firstIsHeader = (firstRow?.content ?? []).every((c) => c.type === "tableHeader")
422
+ const header = firstIsHeader ? pad(allRows[0] ?? []) : Array<string>(colCount).fill("")
423
+ const separator = Array<string>(colCount).fill("---")
424
+ const bodyRows = (firstIsHeader ? allRows.slice(1) : allRows).map(pad)
425
+ const fmt = (cells: Array<string>): string => `| ${cells.join(" | ")} |`
426
+ return [fmt(header), fmt(separator), ...bodyRows.map(fmt)].join("\n")
427
+ }
428
+
429
+ const panel = (n: AdfNode, ctx: Ctx): string => {
430
+ const panelType = attrStr(n, "panelType") ?? "info"
431
+ const tag = PANEL_MAP[panelType] ?? "NOTE"
432
+ const inner = (n.content ?? []).map((c) => block(c, ctx)).join("\n\n")
433
+ const lines = [`[!${tag}]`, ...inner.split("\n")]
434
+ return lines.map((l) => (l.length === 0 ? ">" : `> ${l}`)).join("\n")
435
+ }
436
+
437
+ const expand = (n: AdfNode, ctx: Ctx): string => {
438
+ const title = attrStr(n, "title") ?? ""
439
+ // At block level <details> is a CommonMark type-6 HTML block, so the title
440
+ // needs entity escaping. Inside a table cell the flattened output becomes
441
+ // *inline* HTML where the text between tags is still markdown — there the
442
+ // backslash escapes are the correct (and only working) form.
443
+ const safeTitle = ctx.inTable ? escapeText(title) : escapeHtml(title)
444
+ const inner = (n.content ?? []).map((c) => block(c, ctx)).join("\n\n")
445
+ return `<details><summary>${safeTitle}</summary>\n\n${inner}\n\n</details>`
446
+ }
447
+
448
+ const taskList = (n: AdfNode, ctx: Ctx): string => {
449
+ const items = n.content ?? []
450
+ const lines: Array<string> = []
451
+ for (const item of items) {
452
+ if (item.type !== "taskItem") {
453
+ lines.push(block(item, ctx))
454
+ continue
455
+ }
456
+ const checked = attrStr(item, "state") === "DONE" ? "x" : " "
457
+ const text = inline(item.content, ctx)
458
+ lines.push(`- [${checked}] ${text}`)
459
+ }
460
+ return lines.join("\n")
461
+ }
462
+
463
+ const decisionList = (n: AdfNode, ctx: Ctx): string => {
464
+ const items = n.content ?? []
465
+ const lines: Array<string> = []
466
+ for (const item of items) {
467
+ if (item.type !== "decisionItem") {
468
+ lines.push(block(item, ctx))
469
+ continue
470
+ }
471
+ lines.push(`- 🔘 ${inline(item.content, ctx)}`)
472
+ }
473
+ return lines.join("\n")
474
+ }
475
+
476
+ const renderMedia = (media: AdfNode | undefined, ctx: Ctx): string => {
477
+ const id = (media && attrStr(media, "id")) ?? ""
478
+ const alt = (media && attrStr(media, "alt")) ?? ""
479
+ const url = media && attrStr(media, "url")
480
+ if (url) return `![${sanitizeAlt(alt)}](${safeHref(url)})`
481
+ ctx.warnings.push({ _tag: "MediaWithoutUrl", mediaId: id })
482
+ return `<!-- adf:media id=${id} -->`
483
+ }
484
+
485
+ const mediaSingle = (n: AdfNode, ctx: Ctx): string => {
486
+ const children = n.content ?? []
487
+ const rendered = renderMedia(children.find((c) => c.type === "media"), ctx)
488
+ const caption = children.find((c) => c.type === "caption")
489
+ const captionText = caption ? inline(caption.content, ctx).trim() : ""
490
+ if (captionText.length === 0) return rendered
491
+ // An em-marked caption already renders as `_…_`; wrapping again would make
492
+ // `__…__` (strong). Leave captions that touch an underscore unwrapped.
493
+ const line = captionText.startsWith("_") || captionText.endsWith("_") ? captionText : `_${captionText}_`
494
+ return `${rendered}\n${line}`
495
+ }
496
+
497
+ const mediaGroup = (n: AdfNode, ctx: Ctx): string =>
498
+ (n.content ?? []).map((media) => renderMedia(media, ctx)).join("\n\n")
499
+
500
+ /**
501
+ * Walk an ADF document and emit GFM markdown. Always synchronous; warnings
502
+ * are collected, not thrown.
503
+ */
504
+ export const walk = (doc: DocNode): WalkResult => {
505
+ const ctx: Ctx = { inTable: false, warnings: [] }
506
+ const root = doc as unknown as AdfNode
507
+ const blocks = (root.content ?? []).map((c) => block(c, ctx))
508
+ const body = blocks.join("\n\n")
509
+ const markdown = body.endsWith("\n") ? body : body + "\n"
510
+ return { markdown, warnings: ctx.warnings }
511
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Effect wrapper around the official @atlaskit markdown / JSON transformers.
3
+ *
4
+ * Push direction (markdown → ADF) routes through `MarkdownTransformer.parse()`
5
+ * (markdown → ProseMirror node) followed by `JSONTransformer.encode()`
6
+ * (ProseMirror node → ADF JSON). Both libraries are stateless once
7
+ * constructed, so we instantiate them once at module load.
8
+ *
9
+ * @module
10
+ */
11
+ // Deep import into the published CJS file: Atlaskit's `schema-default`
12
+ // subpath has no `exports` map (Node ESM rejects it as a directory import)
13
+ // and its ESM build uses extensionless relative imports (also rejected).
14
+ // The CJS file works under Node's CJS-into-ESM interop. Types come from the
15
+ // ambient declaration in `atlaskit-adf-schema.d.ts`.
16
+ import { defaultSchema } from "@atlaskit/adf-schema/dist/cjs/schema/default-schema.js"
17
+ import { type JSONDocNode, JSONTransformer } from "@atlaskit/editor-json-transformer"
18
+ import { MarkdownTransformer } from "@atlaskit/editor-markdown-transformer"
19
+ import * as Context from "effect/Context"
20
+ import * as Effect from "effect/Effect"
21
+ import * as Layer from "effect/Layer"
22
+ import { AtlaskitTransformersError } from "./ConfluenceError.js"
23
+
24
+ /**
25
+ * Bag of the two transformer instances handed to the `use` callback.
26
+ */
27
+ export interface Transformers {
28
+ readonly md: MarkdownTransformer
29
+ readonly json: JSONTransformer
30
+ }
31
+
32
+ const md = new MarkdownTransformer(defaultSchema)
33
+ const json = new JSONTransformer(defaultSchema)
34
+ const transformers: Transformers = { md, json }
35
+
36
+ /**
37
+ * Effect service exposing the @atlaskit markdown + JSON transformers via a
38
+ * `use` callback. Errors thrown synchronously by the underlying libraries are
39
+ * surfaced as `AtlaskitTransformersError`.
40
+ *
41
+ * @category Service
42
+ */
43
+ export class AtlaskitTransformers extends Context.Tag(
44
+ "@knpkv/confluence-to-markdown/AtlaskitTransformers"
45
+ )<
46
+ AtlaskitTransformers,
47
+ {
48
+ readonly use: <A>(
49
+ fn: (t: Transformers) => A
50
+ ) => Effect.Effect<A, AtlaskitTransformersError>
51
+ }
52
+ >() {}
53
+
54
+ /**
55
+ * Live Layer providing the wrapped @atlaskit transformers. The transformer
56
+ * instances are module-level singletons; the layer just hands out a
57
+ * `use`-callback service that catches synchronous throws.
58
+ *
59
+ * @category Layers
60
+ */
61
+ export const layer: Layer.Layer<AtlaskitTransformers> = Layer.succeed(
62
+ AtlaskitTransformers,
63
+ AtlaskitTransformers.of({
64
+ use: <A>(fn: (t: Transformers) => A) =>
65
+ Effect.try({
66
+ try: () => fn(transformers),
67
+ catch: (cause) => new AtlaskitTransformersError({ cause })
68
+ })
69
+ })
70
+ )
71
+
72
+ export type { JSONDocNode }
@@ -26,7 +26,7 @@ export interface CreatePageRequest {
26
26
  readonly title: string
27
27
  readonly parentId?: string
28
28
  readonly body: {
29
- readonly representation: "storage"
29
+ readonly representation: "atlas_doc_format"
30
30
  readonly value: string
31
31
  }
32
32
  }
@@ -45,7 +45,7 @@ export interface UpdatePageRequest {
45
45
  readonly message?: string
46
46
  }
47
47
  readonly body: {
48
- readonly representation: "storage"
48
+ readonly representation: "atlas_doc_format"
49
49
  readonly value: string
50
50
  }
51
51
  }
@@ -194,7 +194,7 @@ const make = (
194
194
 
195
195
  const getPage = (id: PageId): Effect.Effect<PageResponse, ApiError | RateLimitError> =>
196
196
  toEffect(apiClient.v2.client.GET("/pages/{id}", {
197
- params: { path: { id: Number(id) }, query: { "body-format": "storage" } }
197
+ params: { path: { id: Number(id) }, query: { "body-format": "atlas_doc_format" } }
198
198
  })).pipe(
199
199
  Effect.mapError((e) => mapApiError(e, `/pages/${id}`, id)),
200
200
  Effect.retry(rateLimitSchedule)
@@ -312,7 +312,7 @@ const make = (
312
312
  params: {
313
313
  path: { id: Number(id) },
314
314
  query: {
315
- ...(options?.includeBody ? { "body-format": "storage" as const } : {}),
315
+ ...(options?.includeBody ? { "body-format": "atlas_doc_format" as const } : {}),
316
316
  ...(cursor ? { cursor } : {}),
317
317
  limit: VERSIONS_PAGE_SIZE
318
318
  }