@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
@@ -0,0 +1,530 @@
1
+ import type { DocNode } from "@atlaskit/adf-schema"
2
+ import { describe, expect, it } from "vitest"
3
+ import { walk } from "../src/AdfWalker.js"
4
+
5
+ const doc = (content: ReadonlyArray<unknown>): DocNode => ({ version: 1, type: "doc", content } as unknown as DocNode)
6
+
7
+ // Expected `attrs=` blob: base64 of the attrs JSON with keys sorted (write
8
+ // the literal in sorted key order so JSON.stringify matches the walker).
9
+ const b64 = (attrs: Record<string, unknown>): string => Buffer.from(JSON.stringify(attrs)).toString("base64")
10
+
11
+ describe("AdfWalker", () => {
12
+ it("emits a heading at the right level", () => {
13
+ const r = walk(doc([{ type: "heading", attrs: { level: 3 }, content: [{ type: "text", text: "Hi" }] }]))
14
+ expect(r.markdown).toContain("### Hi")
15
+ })
16
+
17
+ it("escapes special characters in text", () => {
18
+ const r = walk(doc([{ type: "paragraph", content: [{ type: "text", text: "use *stars*" }] }]))
19
+ expect(r.markdown).toContain("\\*stars\\*")
20
+ })
21
+
22
+ it("does not escape characters that are only special at line-start or in link syntax", () => {
23
+ const r = walk(doc([{
24
+ type: "paragraph",
25
+ content: [{ type: "text", text: "a (b) c+ d! {e} #1 > #2" }]
26
+ }]))
27
+ expect(r.markdown).toContain("a (b) c+ d! {e} #1 > #2")
28
+ })
29
+
30
+ it("does not escape code-marked text", () => {
31
+ // Backslashes inside code spans are literal; escaping here made every
32
+ // pull/push round-trip double them (a\_b → a\\\_b → …).
33
+ const r = walk(doc([{
34
+ type: "paragraph",
35
+ content: [{ type: "text", text: "a_b (c*)", marks: [{ type: "code" }] }]
36
+ }]))
37
+ expect(r.markdown).toContain("`a_b (c*)`")
38
+ })
39
+
40
+ it("fences code spans containing backticks with a longer delimiter", () => {
41
+ const r = walk(doc([{
42
+ type: "paragraph",
43
+ content: [{ type: "text", text: "a `b` c", marks: [{ type: "code" }] }]
44
+ }]))
45
+ expect(r.markdown).toContain("``a `b` c``")
46
+ })
47
+
48
+ it("space-pads code spans that start or end with a backtick", () => {
49
+ const r = walk(doc([{
50
+ type: "paragraph",
51
+ content: [{ type: "text", text: "`tick", marks: [{ type: "code" }] }]
52
+ }]))
53
+ expect(r.markdown).toContain("`` `tick ``")
54
+ })
55
+
56
+ it("renders inline marks", () => {
57
+ const r = walk(doc([{
58
+ type: "paragraph",
59
+ content: [
60
+ { type: "text", text: "a", marks: [{ type: "strong" }] },
61
+ { type: "text", text: "b", marks: [{ type: "em" }] },
62
+ { type: "text", text: "c", marks: [{ type: "code" }] },
63
+ { type: "text", text: "d", marks: [{ type: "strike" }] }
64
+ ]
65
+ }]))
66
+ expect(r.markdown).toContain("**a**")
67
+ expect(r.markdown).toContain("_b_")
68
+ expect(r.markdown).toContain("`c`")
69
+ expect(r.markdown).toContain("~~d~~")
70
+ })
71
+
72
+ it("renders a link with title", () => {
73
+ const r = walk(doc([{
74
+ type: "paragraph",
75
+ content: [{
76
+ type: "text",
77
+ text: "go",
78
+ marks: [{ type: "link", attrs: { href: "https://x.test", title: "T" } }]
79
+ }]
80
+ }]))
81
+ expect(r.markdown).toContain(`[go](https://x.test "T")`)
82
+ })
83
+
84
+ it("falls back lossy marks to HTML and warns", () => {
85
+ const r = walk(doc([{
86
+ type: "paragraph",
87
+ content: [{ type: "text", text: "U", marks: [{ type: "underline" }] }]
88
+ }]))
89
+ expect(r.markdown).toContain("<u>U</u>")
90
+ expect(r.warnings.some((w) => w._tag === "LossyMark" && w.mark === "underline")).toBe(true)
91
+ })
92
+
93
+ it("renders nested bullet lists", () => {
94
+ const r = walk(doc([{
95
+ type: "bulletList",
96
+ content: [{
97
+ type: "listItem",
98
+ content: [
99
+ { type: "paragraph", content: [{ type: "text", text: "outer" }] },
100
+ {
101
+ type: "bulletList",
102
+ content: [{
103
+ type: "listItem",
104
+ content: [{ type: "paragraph", content: [{ type: "text", text: "inner" }] }]
105
+ }]
106
+ }
107
+ ]
108
+ }]
109
+ }]))
110
+ expect(r.markdown).toContain("- outer")
111
+ expect(r.markdown).toContain("inner")
112
+ })
113
+
114
+ it("renders ordered lists with attrs.order", () => {
115
+ const r = walk(doc([{
116
+ type: "orderedList",
117
+ attrs: { order: 5 },
118
+ content: [{
119
+ type: "listItem",
120
+ content: [{ type: "paragraph", content: [{ type: "text", text: "first" }] }]
121
+ }]
122
+ }]))
123
+ expect(r.markdown).toContain("5. first")
124
+ })
125
+
126
+ it("renders a code block with language", () => {
127
+ const r = walk(doc([{
128
+ type: "codeBlock",
129
+ attrs: { language: "ts" },
130
+ content: [{ type: "text", text: "const x = 1" }]
131
+ }]))
132
+ expect(r.markdown).toContain("```ts")
133
+ expect(r.markdown).toContain("const x = 1")
134
+ expect(r.markdown).toContain("```")
135
+ })
136
+
137
+ it("renders a table with header row", () => {
138
+ const r = walk(doc([{
139
+ type: "table",
140
+ content: [
141
+ {
142
+ type: "tableRow",
143
+ content: [
144
+ { type: "tableHeader", content: [{ type: "paragraph", content: [{ type: "text", text: "A" }] }] },
145
+ { type: "tableHeader", content: [{ type: "paragraph", content: [{ type: "text", text: "B" }] }] }
146
+ ]
147
+ },
148
+ {
149
+ type: "tableRow",
150
+ content: [
151
+ { type: "tableCell", content: [{ type: "paragraph", content: [{ type: "text", text: "1" }] }] },
152
+ { type: "tableCell", content: [{ type: "paragraph", content: [{ type: "text", text: "2" }] }] }
153
+ ]
154
+ }
155
+ ]
156
+ }]))
157
+ expect(r.markdown).toContain("| A | B |")
158
+ expect(r.markdown).toContain("| --- | --- |")
159
+ expect(r.markdown).toContain("| 1 | 2 |")
160
+ })
161
+
162
+ it("renders a panel as a GitHub admonition", () => {
163
+ const r = walk(doc([{
164
+ type: "panel",
165
+ attrs: { panelType: "warning" },
166
+ content: [{ type: "paragraph", content: [{ type: "text", text: "be careful" }] }]
167
+ }]))
168
+ expect(r.markdown).toContain("[!WARNING]")
169
+ expect(r.markdown).toContain("be careful")
170
+ })
171
+
172
+ it("renders task lists with checkbox state", () => {
173
+ const r = walk(doc([{
174
+ type: "taskList",
175
+ content: [
176
+ {
177
+ type: "taskItem",
178
+ attrs: { state: "DONE" },
179
+ content: [{ type: "text", text: "done" }]
180
+ },
181
+ {
182
+ type: "taskItem",
183
+ attrs: { state: "TODO" },
184
+ content: [{ type: "text", text: "todo" }]
185
+ }
186
+ ]
187
+ }]))
188
+ expect(r.markdown).toContain("- [x] done")
189
+ expect(r.markdown).toContain("- [ ] todo")
190
+ })
191
+
192
+ it("renders every child of a mediaGroup", () => {
193
+ const r = walk(doc([{
194
+ type: "mediaGroup",
195
+ content: [
196
+ { type: "media", attrs: { id: "m1", alt: "first", url: "https://x.test/1.png" } },
197
+ { type: "media", attrs: { id: "m2", alt: "second", url: "https://x.test/2.png" } },
198
+ { type: "media", attrs: { id: "m3" } }
199
+ ]
200
+ }]))
201
+ expect(r.markdown).toContain("![first](https://x.test/1.png)")
202
+ expect(r.markdown).toContain("![second](https://x.test/2.png)")
203
+ expect(r.markdown).toContain("<!-- adf:media id=m3 -->")
204
+ expect(r.warnings.some((w) => w._tag === "MediaWithoutUrl" && w.mediaId === "m3")).toBe(true)
205
+ })
206
+
207
+ it("emits placeholders + warnings for unknown nodes", () => {
208
+ const r = walk(doc([{ type: "totallyMadeUp" }]))
209
+ expect(r.markdown).toContain("<!-- unsupported ADF node: totallyMadeUp -->")
210
+ expect(r.warnings.some((w) => w._tag === "UnsupportedNode")).toBe(true)
211
+ })
212
+
213
+ it("does not double the @ on mentions whose text already starts with @", () => {
214
+ const r = walk(doc([{
215
+ type: "paragraph",
216
+ content: [{ type: "mention", attrs: { id: "557057:abc", text: "@Andrey Konopkov" } }]
217
+ }]))
218
+ expect(r.markdown).toContain("@Andrey Konopkov")
219
+ expect(r.markdown).not.toContain("@@")
220
+ })
221
+
222
+ it("encodes the mention accountId in a custom-scheme link", () => {
223
+ const r = walk(doc([{
224
+ type: "paragraph",
225
+ content: [{ type: "mention", attrs: { id: "557057:abc-123", text: "@Andrey Konopkov" } }]
226
+ }]))
227
+ // ":" gets percent-encoded by encodeURIComponent so the URL is unambiguous.
228
+ expect(r.markdown).toContain("[@Andrey Konopkov](confluence-mention://557057%3Aabc-123)")
229
+ })
230
+
231
+ it("falls back to plain @text when the mention has no id", () => {
232
+ const r = walk(doc([{
233
+ type: "paragraph",
234
+ content: [{ type: "mention", attrs: { text: "@Anon" } }]
235
+ }]))
236
+ expect(r.markdown).toContain("@Anon")
237
+ expect(r.markdown).not.toContain("confluence-mention")
238
+ })
239
+
240
+ it("preserves the full attrs (parameters included) in the extension placeholder", () => {
241
+ const r = walk(doc([{
242
+ type: "extension",
243
+ attrs: {
244
+ extensionType: "com.atlassian.confluence.macro.core",
245
+ extensionKey: "toc",
246
+ parameters: { macroParams: { maxLevel: { value: "3" } } }
247
+ }
248
+ }]))
249
+ const attrs = b64({
250
+ extensionKey: "toc",
251
+ extensionType: "com.atlassian.confluence.macro.core",
252
+ parameters: { macroParams: { maxLevel: { value: "3" } } }
253
+ })
254
+ expect(r.markdown).toContain(
255
+ `<!-- adf:extension key=toc type=com.atlassian.confluence.macro.core attrs=${attrs} -->`
256
+ )
257
+ expect(
258
+ r.warnings.some((w) =>
259
+ w._tag === "UnsupportedExtension" && w.extensionKey === "toc" && w.nodeType === "extension"
260
+ )
261
+ ).toBe(true)
262
+ })
263
+
264
+ it("emits the same attrs blob regardless of source key order", () => {
265
+ const a = walk(doc([{ type: "extension", attrs: { extensionKey: "toc", extensionType: "t" } }]))
266
+ const b = walk(doc([{ type: "extension", attrs: { extensionType: "t", extensionKey: "toc" } }]))
267
+ expect(a.markdown).toBe(b.markdown)
268
+ })
269
+
270
+ it("handles inline and bodied extensions", () => {
271
+ const r = walk(doc([
272
+ {
273
+ type: "paragraph",
274
+ content: [
275
+ { type: "text", text: "before " },
276
+ { type: "inlineExtension", attrs: { extensionKey: "jira-issue", extensionType: "com.example" } },
277
+ { type: "text", text: " after" }
278
+ ]
279
+ },
280
+ {
281
+ type: "bodiedExtension",
282
+ attrs: { extensionKey: "details", extensionType: "com.example" },
283
+ content: [{ type: "paragraph", content: [{ type: "text", text: "body" }] }]
284
+ }
285
+ ]))
286
+ const inlineAttrs = b64({ extensionKey: "jira-issue", extensionType: "com.example" })
287
+ const bodiedAttrs = b64({ extensionKey: "details", extensionType: "com.example" })
288
+ expect(r.markdown).toContain(
289
+ `<!-- adf:inlineExtension key=jira-issue type=com.example attrs=${inlineAttrs} -->`
290
+ )
291
+ // The bodied extension renders its body between an open and an end marker
292
+ // so the push side can re-attach it.
293
+ expect(r.markdown).toContain(
294
+ `<!-- adf:bodiedExtension key=details type=com.example attrs=${bodiedAttrs} -->\n\nbody\n\n<!-- adf:/bodiedExtension -->`
295
+ )
296
+ expect(r.warnings.filter((w) => w._tag === "UnsupportedExtension")).toHaveLength(2)
297
+ })
298
+
299
+ it("emits the end marker even for an empty bodied extension", () => {
300
+ // Without it the push side cannot tell "bodied macro with empty body"
301
+ // apart from a legacy/corrupted open marker, and would change node type.
302
+ const r = walk(doc([{
303
+ type: "bodiedExtension",
304
+ attrs: { extensionKey: "excerpt", extensionType: "com.example" },
305
+ content: [{ type: "paragraph", content: [] }]
306
+ }]))
307
+ expect(r.markdown).toContain("<!-- adf:/bodiedExtension -->")
308
+ })
309
+
310
+ it("emits only the single-line marker for a bodied extension inside a table cell", () => {
311
+ // <br>-flattened multi-block emission cannot be reverted on push; the
312
+ // bare marker at least comes back as a clean extension node.
313
+ const r = walk(doc([{
314
+ type: "table",
315
+ content: [{
316
+ type: "tableRow",
317
+ content: [{
318
+ type: "tableCell",
319
+ content: [{
320
+ type: "bodiedExtension",
321
+ attrs: { extensionKey: "details", extensionType: "com.example" },
322
+ content: [{ type: "paragraph", content: [{ type: "text", text: "body" }] }]
323
+ }]
324
+ }]
325
+ }]
326
+ }]))
327
+ expect(r.markdown).not.toContain("adf:/bodiedExtension")
328
+ expect(r.markdown).not.toContain("body")
329
+ expect(r.markdown).toContain("<!-- adf:bodiedExtension key=details type=com.example")
330
+ })
331
+
332
+ it("escapes a pipe in a table cell exactly once", () => {
333
+ const cell = (content: ReadonlyArray<unknown>) => ({
334
+ type: "tableCell",
335
+ content: [{ type: "paragraph", content }]
336
+ })
337
+ const r = walk(doc([{
338
+ type: "table",
339
+ content: [{
340
+ type: "tableRow",
341
+ content: [
342
+ cell([{ type: "text", text: "a|b" }]),
343
+ // Code spans skip escapeText, so this pipe is only caught by the
344
+ // table-cell pass — both cells must end up single-escaped.
345
+ cell([{ type: "text", text: "x|y", marks: [{ type: "code" }] }])
346
+ ]
347
+ }]
348
+ }]))
349
+ expect(r.markdown).toContain("a\\|b")
350
+ expect(r.markdown).not.toContain("a\\\\|b")
351
+ expect(r.markdown).toContain("`x\\|y`")
352
+ })
353
+
354
+ it("renders a mediaSingle caption as an italic line under the media", () => {
355
+ const r = walk(doc([{
356
+ type: "mediaSingle",
357
+ content: [
358
+ { type: "media", attrs: { id: "m1", alt: "diagram", url: "https://x.test/d.png" } },
359
+ { type: "caption", content: [{ type: "text", text: "Figure 1" }] }
360
+ ]
361
+ }]))
362
+ expect(r.markdown).toContain("![diagram](https://x.test/d.png)\n_Figure 1_")
363
+ })
364
+
365
+ it("backslash-escapes nestedExpand titles inside table cells (inline HTML context)", () => {
366
+ const r = walk(doc([{
367
+ type: "table",
368
+ content: [{
369
+ type: "tableRow",
370
+ content: [{
371
+ type: "tableCell",
372
+ content: [{
373
+ type: "nestedExpand",
374
+ attrs: { title: "v2 *beta*" },
375
+ content: [{ type: "paragraph", content: [{ type: "text", text: "inner" }] }]
376
+ }]
377
+ }]
378
+ }]
379
+ }]))
380
+ expect(r.markdown).toContain("<summary>v2 \\*beta\\*</summary>")
381
+ })
382
+
383
+ it("entity-escapes expand titles instead of backslash-escaping them", () => {
384
+ const r = walk(doc([{
385
+ type: "expand",
386
+ attrs: { title: `v2 *beta* <a href="x">` },
387
+ content: [{ type: "paragraph", content: [{ type: "text", text: "inner" }] }]
388
+ }]))
389
+ expect(r.markdown).toContain(`<summary>v2 *beta* &lt;a href=&quot;x&quot;&gt;</summary>`)
390
+ expect(r.markdown).not.toContain("\\*beta\\*")
391
+ })
392
+
393
+ it("lengthens the code-block fence when the code contains backtick runs", () => {
394
+ const r = walk(doc([{
395
+ type: "codeBlock",
396
+ attrs: { language: "md" },
397
+ content: [{ type: "text", text: "```js\ncode\n```" }]
398
+ }]))
399
+ expect(r.markdown).toContain("````md\n```js\ncode\n```\n````")
400
+ })
401
+
402
+ it("sanitizes media alt text and wraps unsafe media urls", () => {
403
+ // Brackets are substituted, not backslash-escaped: @atlaskit's media
404
+ // markdown plugin throws on `\[` in alt, which would make pushes fail.
405
+ const r = walk(doc([{
406
+ type: "mediaSingle",
407
+ content: [{
408
+ type: "media",
409
+ attrs: { id: "m1", alt: "a [b]\nc", url: "https://x.test/a (1).png" }
410
+ }]
411
+ }]))
412
+ expect(r.markdown).toContain("![a (b) c](<https://x.test/a (1).png>)")
413
+ })
414
+
415
+ it("percent-encodes wrapper-breaking characters in unsafe urls", () => {
416
+ const r = walk(doc([{
417
+ type: "paragraph",
418
+ content: [{
419
+ type: "text",
420
+ text: "go",
421
+ marks: [{ type: "link", attrs: { href: "https://x.test/a b<c>d\\e" } }]
422
+ }]
423
+ }]))
424
+ expect(r.markdown).toContain("[go](<https://x.test/a b%3Cc%3Ed%5Ce>)")
425
+ })
426
+
427
+ it("escapes block markers at line start so text cannot become structure", () => {
428
+ const r = walk(doc([
429
+ { type: "paragraph", content: [{ type: "text", text: "# not a heading" }] },
430
+ { type: "paragraph", content: [{ type: "text", text: "> not a quote" }] },
431
+ { type: "paragraph", content: [{ type: "text", text: "+ not a list" }] },
432
+ { type: "paragraph", content: [{ type: "text", text: "1. not a list" }] },
433
+ {
434
+ type: "paragraph",
435
+ content: [
436
+ { type: "text", text: "a" },
437
+ { type: "hardBreak" },
438
+ { type: "text", text: "# b stays text" }
439
+ ]
440
+ }
441
+ ]))
442
+ expect(r.markdown).toContain("\\# not a heading")
443
+ expect(r.markdown).toContain("\\> not a quote")
444
+ expect(r.markdown).toContain("\\+ not a list")
445
+ expect(r.markdown).toContain("1\\. not a list")
446
+ expect(r.markdown).toContain("\\# b stays text")
447
+ })
448
+
449
+ it("does not escape mid-line hashes or list-like text after the first word", () => {
450
+ const r = walk(doc([{ type: "paragraph", content: [{ type: "text", text: "a #1 > #2 + b" }] }]))
451
+ expect(r.markdown).toContain("a #1 > #2 + b")
452
+ })
453
+
454
+ it("strips backticks and whitespace from the code-block language", () => {
455
+ const r = walk(doc([{
456
+ type: "codeBlock",
457
+ attrs: { language: "c`x\ninjected" },
458
+ content: [{ type: "text", text: "hello" }]
459
+ }]))
460
+ expect(r.markdown).toContain("```cxinjected\nhello\n```")
461
+ })
462
+
463
+ it("warns when an inlineCard has no url to render", () => {
464
+ const r = walk(doc([{
465
+ type: "paragraph",
466
+ content: [
467
+ { type: "text", text: "before " },
468
+ { type: "inlineCard", attrs: { data: { url: "https://hidden.test" } } },
469
+ { type: "text", text: " after" }
470
+ ]
471
+ }]))
472
+ expect(r.warnings.some((w) => w._tag === "UnsupportedNode" && w.nodeType === "inlineCard")).toBe(true)
473
+ })
474
+
475
+ it("does not double-wrap an em-marked caption", () => {
476
+ const r = walk(doc([{
477
+ type: "mediaSingle",
478
+ content: [
479
+ { type: "media", attrs: { id: "m1", url: "https://x.test/d.png" } },
480
+ { type: "caption", content: [{ type: "text", text: "a caption", marks: [{ type: "em" }] }] }
481
+ ]
482
+ }]))
483
+ expect(r.markdown).toContain("_a caption_")
484
+ expect(r.markdown).not.toContain("__a caption__")
485
+ })
486
+
487
+ it("omits the caption line when the caption is only whitespace", () => {
488
+ const r = walk(doc([{
489
+ type: "mediaSingle",
490
+ content: [
491
+ { type: "media", attrs: { id: "m1", url: "https://x.test/d.png" } },
492
+ { type: "caption", content: [{ type: "text", text: " " }] }
493
+ ]
494
+ }]))
495
+ expect(r.markdown).toContain("![](https://x.test/d.png)")
496
+ expect(r.markdown).not.toContain("_")
497
+ })
498
+
499
+ it("never renders a caption as the media when the media child is missing", () => {
500
+ const r = walk(doc([{
501
+ type: "mediaSingle",
502
+ content: [{ type: "caption", content: [{ type: "text", text: "orphan" }] }]
503
+ }]))
504
+ expect(r.markdown).toContain("<!-- adf:media id= -->")
505
+ expect(r.markdown).toContain("_orphan_")
506
+ })
507
+
508
+ it("maps note panels to IMPORTANT and success panels to TIP", () => {
509
+ const r = walk(doc([
510
+ {
511
+ type: "panel",
512
+ attrs: { panelType: "note" },
513
+ content: [{ type: "paragraph", content: [{ type: "text", text: "n" }] }]
514
+ },
515
+ {
516
+ type: "panel",
517
+ attrs: { panelType: "success" },
518
+ content: [{ type: "paragraph", content: [{ type: "text", text: "s" }] }]
519
+ }
520
+ ]))
521
+ expect(r.markdown).toContain("[!IMPORTANT]")
522
+ expect(r.markdown).toContain("[!TIP]")
523
+ })
524
+
525
+ it("ends output with exactly one newline", () => {
526
+ const r = walk(doc([{ type: "paragraph", content: [{ type: "text", text: "x" }] }]))
527
+ expect(r.markdown.endsWith("\n")).toBe(true)
528
+ expect(r.markdown.endsWith("\n\n")).toBe(false)
529
+ })
530
+ })
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import * as Effect from "effect/Effect"
3
+ import { AtlaskitTransformers, layer as AtlaskitTransformersLayer } from "../src/AtlaskitTransformers.js"
4
+
5
+ describe("AtlaskitTransformers", () => {
6
+ it.effect("encodes markdown to ADF JSON", () =>
7
+ Effect.gen(function*() {
8
+ const t = yield* AtlaskitTransformers
9
+ const adf = yield* t.use(({ json, md }) => json.encode(md.parse("# Hello")))
10
+ expect(adf.type).toBe("doc")
11
+ expect(adf.content[0]?.type).toBe("heading")
12
+ }).pipe(Effect.provide(AtlaskitTransformersLayer)))
13
+
14
+ it.effect("surfaces synchronous throws as AtlaskitTransformersError", () =>
15
+ Effect.gen(function*() {
16
+ const t = yield* AtlaskitTransformers
17
+ const result = yield* Effect.either(t.use(() => {
18
+ throw new Error("boom")
19
+ }))
20
+ expect(result._tag).toBe("Left")
21
+ if (result._tag === "Left") {
22
+ expect(result.left._tag).toBe("AtlaskitTransformersError")
23
+ }
24
+ }).pipe(Effect.provide(AtlaskitTransformersLayer)))
25
+ })