@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,142 +1,157 @@
1
1
  import { describe, expect, it } from "@effect/vitest"
2
2
  import * as Effect from "effect/Effect"
3
- import { layer as MarkdownConverterLayer, MarkdownConverter, schemaBasedLayer } from "../src/MarkdownConverter.js"
3
+ import * as Layer from "effect/Layer"
4
+ import { layer as AdfSchemaValidatorLayer } from "../src/AdfSchemaValidator.js"
5
+ import { layer as AtlaskitTransformersLayer } from "../src/AtlaskitTransformers.js"
6
+ import { layer as MarkdownConverterLayer, MarkdownConverter } from "../src/MarkdownConverter.js"
4
7
 
5
- describe("MarkdownConverter", () => {
6
- describe("htmlToMarkdown", () => {
7
- it.effect("converts basic HTML to markdown", () =>
8
- Effect.gen(function*() {
9
- const converter = yield* MarkdownConverter
10
- const html = "<p>Hello <strong>world</strong></p>"
11
- const markdown = yield* converter.htmlToMarkdown(html)
12
- expect(markdown).toContain("Hello")
13
- expect(markdown).toContain("**world**")
14
- }).pipe(Effect.provide(MarkdownConverterLayer)))
8
+ const TestLayer = MarkdownConverterLayer.pipe(
9
+ Layer.provide(AtlaskitTransformersLayer),
10
+ Layer.provide(AdfSchemaValidatorLayer)
11
+ )
15
12
 
16
- it.effect("converts headings", () =>
17
- Effect.gen(function*() {
18
- const converter = yield* MarkdownConverter
19
- const html = "<h1>Title</h1><h2>Subtitle</h2>"
20
- const markdown = yield* converter.htmlToMarkdown(html)
21
- expect(markdown).toContain("# Title")
22
- expect(markdown).toContain("## Subtitle")
23
- }).pipe(Effect.provide(MarkdownConverterLayer)))
13
+ const minimalDoc = (content: ReadonlyArray<unknown>): string => JSON.stringify({ version: 1, type: "doc", content })
24
14
 
25
- it.effect("converts lists", () =>
26
- Effect.gen(function*() {
27
- const converter = yield* MarkdownConverter
28
- const html = "<ul><li>Item 1</li><li>Item 2</li></ul>"
29
- const markdown = yield* converter.htmlToMarkdown(html)
30
- // AST-based serializer uses - for unordered lists
31
- expect(markdown).toContain("- Item 1")
32
- expect(markdown).toContain("- Item 2")
33
- }).pipe(Effect.provide(MarkdownConverterLayer)))
34
-
35
- it.effect("converts links", () =>
15
+ describe("MarkdownConverter", () => {
16
+ describe("adfToMarkdown", () => {
17
+ it.effect("converts a heading", () =>
36
18
  Effect.gen(function*() {
37
19
  const converter = yield* MarkdownConverter
38
- const html = "<a href=\"https://example.com\">Link</a>"
39
- const markdown = yield* converter.htmlToMarkdown(html)
40
- expect(markdown).toContain("[Link](https://example.com)")
41
- }).pipe(Effect.provide(MarkdownConverterLayer)))
20
+ const md = yield* converter.adfToMarkdown(
21
+ minimalDoc([{ type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "Hello" }] }])
22
+ )
23
+ expect(md).toContain("# Hello")
24
+ }).pipe(Effect.provide(TestLayer)))
42
25
 
43
- it.effect("converts code blocks", () =>
26
+ it.effect("converts a paragraph with marks", () =>
44
27
  Effect.gen(function*() {
45
28
  const converter = yield* MarkdownConverter
46
- const html = "<pre><code>const x = 1;</code></pre>"
47
- const markdown = yield* converter.htmlToMarkdown(html)
48
- expect(markdown).toContain("```")
49
- expect(markdown).toContain("const x = 1;")
50
- }).pipe(Effect.provide(MarkdownConverterLayer)))
29
+ const md = yield* converter.adfToMarkdown(
30
+ minimalDoc([{
31
+ type: "paragraph",
32
+ content: [
33
+ { type: "text", text: "Hello " },
34
+ { type: "text", text: "world", marks: [{ type: "strong" }] }
35
+ ]
36
+ }])
37
+ )
38
+ expect(md).toContain("Hello")
39
+ expect(md).toContain("**world**")
40
+ }).pipe(Effect.provide(TestLayer)))
51
41
 
52
- it.effect("strips Confluence macros with rich-text-body", () =>
42
+ it.effect("converts a bullet list", () =>
53
43
  Effect.gen(function*() {
54
44
  const converter = yield* MarkdownConverter
55
- const html =
56
- "<ac:structured-macro ac:name=\"info\"><ac:rich-text-body><p>Content</p></ac:rich-text-body></ac:structured-macro>"
57
- const markdown = yield* converter.htmlToMarkdown(html)
58
- expect(markdown).toContain("Content")
59
- expect(markdown).not.toContain("ac:structured-macro")
60
- }).pipe(Effect.provide(MarkdownConverterLayer)))
45
+ const md = yield* converter.adfToMarkdown(
46
+ minimalDoc([{
47
+ type: "bulletList",
48
+ content: [
49
+ {
50
+ type: "listItem",
51
+ content: [{ type: "paragraph", content: [{ type: "text", text: "one" }] }]
52
+ },
53
+ {
54
+ type: "listItem",
55
+ content: [{ type: "paragraph", content: [{ type: "text", text: "two" }] }]
56
+ }
57
+ ]
58
+ }])
59
+ )
60
+ expect(md).toContain("- one")
61
+ expect(md).toContain("- two")
62
+ }).pipe(Effect.provide(TestLayer)))
61
63
 
62
- it.effect("converts Confluence code macros to code blocks", () =>
64
+ it.effect("converts a code block with language", () =>
63
65
  Effect.gen(function*() {
64
66
  const converter = yield* MarkdownConverter
65
- const html =
66
- "<ac:structured-macro ac:name=\"code\"><ac:plain-text-body><![CDATA[const x = 1;]]></ac:plain-text-body></ac:structured-macro>"
67
- const markdown = yield* converter.htmlToMarkdown(html)
68
- expect(markdown).toContain("const x = 1;")
69
- expect(markdown).toContain("```")
70
- }).pipe(Effect.provide(MarkdownConverterLayer)))
71
- })
67
+ const md = yield* converter.adfToMarkdown(
68
+ minimalDoc([{
69
+ type: "codeBlock",
70
+ attrs: { language: "ts" },
71
+ content: [{ type: "text", text: "const x = 1" }]
72
+ }])
73
+ )
74
+ expect(md).toContain("```ts")
75
+ expect(md).toContain("const x = 1")
76
+ }).pipe(Effect.provide(TestLayer)))
72
77
 
73
- describe("markdownToHtml", () => {
74
- it.effect("converts basic markdown to HTML", () =>
78
+ it.effect("converts a panel to a GitHub admonition", () =>
75
79
  Effect.gen(function*() {
76
80
  const converter = yield* MarkdownConverter
77
- const markdown = "Hello **world**"
78
- const html = yield* converter.markdownToHtml(markdown)
79
- expect(html).toContain("<strong>world</strong>")
80
- }).pipe(Effect.provide(MarkdownConverterLayer)))
81
+ const md = yield* converter.adfToMarkdown(
82
+ minimalDoc([{
83
+ type: "panel",
84
+ attrs: { panelType: "info" },
85
+ content: [{ type: "paragraph", content: [{ type: "text", text: "heads up" }] }]
86
+ }])
87
+ )
88
+ expect(md).toContain("[!NOTE]")
89
+ expect(md).toContain("heads up")
90
+ }).pipe(Effect.provide(TestLayer)))
81
91
 
82
- it.effect("converts headings", () =>
92
+ it.effect("fails with ConversionError on invalid JSON", () =>
83
93
  Effect.gen(function*() {
84
94
  const converter = yield* MarkdownConverter
85
- const markdown = "# Title\n\n## Subtitle"
86
- const html = yield* converter.markdownToHtml(markdown)
87
- expect(html).toContain("<h1>Title</h1>")
88
- expect(html).toContain("<h2>Subtitle</h2>")
89
- }).pipe(Effect.provide(MarkdownConverterLayer)))
95
+ const result = yield* Effect.either(converter.adfToMarkdown("not json"))
96
+ expect(result._tag).toBe("Left")
97
+ if (result._tag === "Left") {
98
+ expect(result.left._tag).toBe("ConversionError")
99
+ expect(result.left.direction).toBe("adfToMarkdown")
100
+ }
101
+ }).pipe(Effect.provide(TestLayer)))
90
102
 
91
- it.effect("converts GFM tables", () =>
103
+ it.effect("treats schema-invalid incoming ADF as advisory and still converts", () =>
92
104
  Effect.gen(function*() {
93
105
  const converter = yield* MarkdownConverter
94
- const markdown = "| A | B |\n|---|---|\n| 1 | 2 |"
95
- const html = yield* converter.markdownToHtml(markdown)
96
- expect(html).toContain("<table>")
97
- expect(html).toContain("<th>A</th>")
98
- expect(html).toContain("<td>1</td>")
99
- }).pipe(Effect.provide(MarkdownConverterLayer)))
106
+ // Missing `version`, and `attrs.level` should be a number both are
107
+ // schema violations Confluence would never produce, but representative
108
+ // of the schema drift we tolerate on the incoming side.
109
+ const md = yield* converter.adfToMarkdown(JSON.stringify({
110
+ type: "doc",
111
+ content: [{
112
+ type: "heading",
113
+ attrs: { level: "1" },
114
+ content: [{ type: "text", text: "Hello" }]
115
+ }]
116
+ }))
117
+ expect(md).toContain("Hello")
118
+ }).pipe(Effect.provide(TestLayer)))
100
119
 
101
- it.effect("converts task lists", () =>
120
+ it.effect("still fails on input too malformed to walk", () =>
102
121
  Effect.gen(function*() {
122
+ // Advisory validation tolerates schema drift, not non-documents:
123
+ // walking `null` is a defect, `{}`/arrays silently produce an empty
124
+ // page that could overwrite a real local file.
103
125
  const converter = yield* MarkdownConverter
104
- const markdown = "- [ ] Todo\n- [x] Done"
105
- const html = yield* converter.markdownToHtml(markdown)
106
- expect(html).toContain("checkbox")
107
- }).pipe(Effect.provide(MarkdownConverterLayer)))
126
+ for (const bad of ["null", "{}", "[1,2]", `{"type":"doc","content":"not an array"}`]) {
127
+ const result = yield* Effect.either(converter.adfToMarkdown(bad))
128
+ expect(result._tag).toBe("Left")
129
+ if (result._tag === "Left") {
130
+ expect(result.left._tag).toBe("ConversionError")
131
+ }
132
+ }
133
+ }).pipe(Effect.provide(TestLayer)))
108
134
  })
109
135
 
110
- describe("schemaBasedLayer", () => {
111
- it.effect("htmlToMarkdown converts basic HTML", () =>
112
- Effect.gen(function*() {
113
- const converter = yield* MarkdownConverter
114
- const html = "<h1>Title</h1><p>Hello world</p>"
115
- const markdown = yield* converter.htmlToMarkdown(html)
116
- expect(markdown).toContain("# Title")
117
- expect(markdown).toContain("Hello world")
118
- }).pipe(Effect.provide(schemaBasedLayer)))
119
-
120
- it.effect("htmlToAst parses HTML to Document", () =>
136
+ describe("markdownToAdf", () => {
137
+ it.effect("produces a valid ADF doc for a heading", () =>
121
138
  Effect.gen(function*() {
122
139
  const converter = yield* MarkdownConverter
123
- const html = "<h1>Title</h1><p>Content</p>"
124
- const doc = yield* converter.htmlToAst(html)
125
- expect(doc.version).toBe(1)
126
- expect(doc.children.length).toBe(2)
127
- expect(doc.children[0]?._tag).toBe("Heading")
128
- expect(doc.children[1]?._tag).toBe("Paragraph")
129
- }).pipe(Effect.provide(schemaBasedLayer)))
140
+ const adf = yield* converter.markdownToAdf("# Title\n\nBody")
141
+ const parsed = JSON.parse(adf) as { type: string; version: number; content: ReadonlyArray<{ type: string }> }
142
+ expect(parsed.type).toBe("doc")
143
+ expect(parsed.version).toBe(1)
144
+ expect(parsed.content[0]?.type).toBe("heading")
145
+ }).pipe(Effect.provide(TestLayer)))
130
146
 
131
- it.effect("markdownToAst parses Markdown to Document", () =>
147
+ it.effect("round-trips a heading + paragraph through both directions", () =>
132
148
  Effect.gen(function*() {
133
149
  const converter = yield* MarkdownConverter
134
- const md = "# Title\n\nContent"
135
- const doc = yield* converter.markdownToAst(md)
136
- expect(doc.version).toBe(1)
137
- expect(doc.children.length).toBe(2)
138
- expect(doc.children[0]?._tag).toBe("Heading")
139
- expect(doc.children[1]?._tag).toBe("Paragraph")
140
- }).pipe(Effect.provide(schemaBasedLayer)))
150
+ const md1 = "# Title\n\nHello **world**"
151
+ const adf = yield* converter.markdownToAdf(md1)
152
+ const md2 = yield* converter.adfToMarkdown(adf)
153
+ expect(md2).toContain("# Title")
154
+ expect(md2).toContain("**world**")
155
+ }).pipe(Effect.provide(TestLayer)))
141
156
  })
142
157
  })
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Markdown → ADF → Markdown round-trip fidelity tests for the new pipeline.
3
+ *
4
+ * Replaces the structural fidelity coverage previously held by the deleted
5
+ * `ConfluenceParser`/`MarkdownParser`/`ConversionSchema`/fixture tests. We
6
+ * pin the *substantive* output of the round-trip (heading structure, list
7
+ * markers, code-block fences, table layout, link/title fidelity) rather than
8
+ * exact whitespace, since the @atlaskit transformer normalizes whitespace
9
+ * and we don't want to over-couple to its emission.
10
+ */
11
+ import { describe, expect, it } from "@effect/vitest"
12
+ import * as Effect from "effect/Effect"
13
+ import * as Layer from "effect/Layer"
14
+ import { layer as AdfSchemaValidatorLayer } from "../src/AdfSchemaValidator.js"
15
+ import { layer as AtlaskitTransformersLayer } from "../src/AtlaskitTransformers.js"
16
+ import { layer as MarkdownConverterLayer, MarkdownConverter } from "../src/MarkdownConverter.js"
17
+
18
+ const TestLayer = MarkdownConverterLayer.pipe(
19
+ Layer.provide(AtlaskitTransformersLayer),
20
+ Layer.provide(AdfSchemaValidatorLayer)
21
+ )
22
+
23
+ const roundTrip = (source: string) =>
24
+ Effect.gen(function*() {
25
+ const converter = yield* MarkdownConverter
26
+ const adf = yield* converter.markdownToAdf(source)
27
+ return yield* converter.adfToMarkdown(adf)
28
+ })
29
+
30
+ describe("MarkdownConverter round-trip", () => {
31
+ it.effect("preserves nested headings", () =>
32
+ Effect.gen(function*() {
33
+ const md = yield* roundTrip("# H1\n\n## H2\n\n### H3\n")
34
+ expect(md).toContain("# H1")
35
+ expect(md).toContain("## H2")
36
+ expect(md).toContain("### H3")
37
+ }).pipe(Effect.provide(TestLayer)))
38
+
39
+ it.effect("preserves a fenced code block with language", () =>
40
+ Effect.gen(function*() {
41
+ const md = yield* roundTrip("```ts\nconst x: number = 1\n```\n")
42
+ expect(md).toContain("```ts")
43
+ expect(md).toContain("const x: number = 1")
44
+ }).pipe(Effect.provide(TestLayer)))
45
+
46
+ it.effect("preserves a blockquote", () =>
47
+ Effect.gen(function*() {
48
+ const md = yield* roundTrip("> a quote\n")
49
+ expect(md).toMatch(/^> a quote/m)
50
+ }).pipe(Effect.provide(TestLayer)))
51
+
52
+ it.effect("preserves a bullet list", () =>
53
+ Effect.gen(function*() {
54
+ const md = yield* roundTrip("- one\n- two\n- three\n")
55
+ expect(md).toContain("- one")
56
+ expect(md).toContain("- two")
57
+ expect(md).toContain("- three")
58
+ }).pipe(Effect.provide(TestLayer)))
59
+
60
+ it.effect("preserves an ordered list", () =>
61
+ Effect.gen(function*() {
62
+ const md = yield* roundTrip("1. one\n2. two\n3. three\n")
63
+ expect(md).toMatch(/1\. one/)
64
+ expect(md).toMatch(/2\. two/)
65
+ expect(md).toMatch(/3\. three/)
66
+ }).pipe(Effect.provide(TestLayer)))
67
+
68
+ it.effect("preserves a GFM table with header", () =>
69
+ Effect.gen(function*() {
70
+ const md = yield* roundTrip("| A | B |\n| --- | --- |\n| 1 | 2 |\n")
71
+ expect(md).toContain("| A | B |")
72
+ expect(md).toContain("| --- | --- |")
73
+ expect(md).toContain("| 1 | 2 |")
74
+ }).pipe(Effect.provide(TestLayer)))
75
+
76
+ // The @atlaskit markdown transformer drops link titles when parsing
77
+ // markdown (its ProseMirror schema for `link` does not capture `title`).
78
+ // So we only assert that text + href survive the round-trip; the title
79
+ // attribute is documented as round-trip-lossy via this test's name.
80
+ it.effect("preserves link text and href (title is lossy via @atlaskit)", () =>
81
+ Effect.gen(function*() {
82
+ const md = yield* roundTrip(`[home](https://example.com "Home")\n`)
83
+ expect(md).toContain("[home]")
84
+ expect(md).toContain("https://example.com")
85
+ }).pipe(Effect.provide(TestLayer)))
86
+
87
+ it.effect("preserves bold, italic, and inline code combinations", () =>
88
+ Effect.gen(function*() {
89
+ const md = yield* roundTrip("a **b** _c_ `d` text\n")
90
+ expect(md).toContain("**b**")
91
+ expect(md).toContain("_c_")
92
+ expect(md).toContain("`d`")
93
+ }).pipe(Effect.provide(TestLayer)))
94
+
95
+ it.effect("does not over-escape ordinary version strings", () =>
96
+ Effect.gen(function*() {
97
+ const md = yield* roundTrip("Released v1.0.0 on 2026-05-03\n")
98
+ expect(md).toContain("v1.0.0")
99
+ expect(md).toContain("2026-05-03")
100
+ expect(md).not.toContain("v1\\.0\\.0")
101
+ expect(md).not.toContain("2026\\-05\\-03")
102
+ }).pipe(Effect.provide(TestLayer)))
103
+
104
+ it.effect("does not escape parentheses or other line-start-only characters", () =>
105
+ Effect.gen(function*() {
106
+ const md = yield* roundTrip("a (b) c+ d!\n")
107
+ expect(md).toContain("a (b) c+ d!")
108
+ expect(md).not.toContain("\\(")
109
+ expect(md).not.toContain("\\+")
110
+ }).pipe(Effect.provide(TestLayer)))
111
+
112
+ // Regression: escaping inside code spans put literal backslashes into the
113
+ // ADF text, which the next pull re-escaped — doubling them on every
114
+ // round-trip (`a_b` → `a\_b` → `a\\\_b` → …).
115
+ it.effect("code spans with markdown-special characters are a round-trip fixed point", () =>
116
+ Effect.gen(function*() {
117
+ const source = "x `a_b` y `c()` (z)\n"
118
+ const once = yield* roundTrip(source)
119
+ const twice = yield* roundTrip(once)
120
+ expect(once).toContain("`a_b`")
121
+ expect(once).toContain("`c()`")
122
+ expect(once).not.toContain("\\")
123
+ expect(twice).toBe(once)
124
+ }).pipe(Effect.provide(TestLayer)))
125
+
126
+ it.effect("preserves an inline status placeholder through round-trip", () =>
127
+ Effect.gen(function*() {
128
+ const md = yield* roundTrip(
129
+ `Status: <span class="adf-status" data-color="blue">TESTING</span>\n`
130
+ )
131
+ expect(md).toContain(`<span class="adf-status" data-color="blue">TESTING</span>`)
132
+ }).pipe(Effect.provide(TestLayer)))
133
+
134
+ it.effect("upgrades a legacy block extension placeholder to the attrs form, then stays fixed", () =>
135
+ Effect.gen(function*() {
136
+ const md = yield* roundTrip(
137
+ `<!-- adf:extension key=toc type=com.atlassian.confluence.macro.core -->\n`
138
+ )
139
+ expect(md).toContain("<!-- adf:extension key=toc type=com.atlassian.confluence.macro.core attrs=")
140
+ const again = yield* roundTrip(md)
141
+ expect(again).toBe(md)
142
+ }).pipe(Effect.provide(TestLayer)))
143
+
144
+ it.effect("round-trips macro parameters through the placeholder attrs blob", () =>
145
+ Effect.gen(function*() {
146
+ const converter = yield* MarkdownConverter
147
+ const attrs = {
148
+ extensionKey: "toc",
149
+ extensionType: "com.atlassian.confluence.macro.core",
150
+ layout: "default",
151
+ localId: "abc-123",
152
+ parameters: { macroParams: { maxLevel: { value: "3" } } }
153
+ }
154
+ const md = yield* converter.adfToMarkdown(JSON.stringify({
155
+ version: 1,
156
+ type: "doc",
157
+ content: [{ type: "extension", attrs }]
158
+ }))
159
+ const adfOut = JSON.parse(yield* converter.markdownToAdf(md)) as {
160
+ content: Array<{ type: string; attrs: Record<string, unknown> }>
161
+ }
162
+ expect(adfOut.content[0]).toEqual({ type: "extension", attrs })
163
+ }).pipe(Effect.provide(TestLayer)))
164
+
165
+ it.effect("round-trips a bodiedExtension with its body re-attached", () =>
166
+ Effect.gen(function*() {
167
+ const converter = yield* MarkdownConverter
168
+ const attrs = { extensionKey: "details", extensionType: "com.atlassian.confluence.macro.core" }
169
+ const md = yield* converter.adfToMarkdown(JSON.stringify({
170
+ version: 1,
171
+ type: "doc",
172
+ content: [{
173
+ type: "bodiedExtension",
174
+ attrs,
175
+ content: [
176
+ { type: "paragraph", content: [{ type: "text", text: "first body paragraph" }] },
177
+ { type: "paragraph", content: [{ type: "text", text: "second body paragraph" }] }
178
+ ]
179
+ }]
180
+ }))
181
+ const adfOut = JSON.parse(yield* converter.markdownToAdf(md)) as {
182
+ content: Array<{ type: string; attrs: Record<string, unknown>; content: Array<unknown> }>
183
+ }
184
+ expect(adfOut.content).toHaveLength(1)
185
+ expect(adfOut.content[0]).toMatchObject({ type: "bodiedExtension", attrs })
186
+ expect(adfOut.content[0]!.content).toEqual([
187
+ { type: "paragraph", content: [{ type: "text", text: "first body paragraph" }] },
188
+ { type: "paragraph", content: [{ type: "text", text: "second body paragraph" }] }
189
+ ])
190
+ }).pipe(Effect.provide(TestLayer)))
191
+
192
+ // Regression: `|` in a cell was escaped twice (escapeText + the table-cell
193
+ // pass), emitting `\\|` — GFM reads that as literal backslash + bare pipe,
194
+ // which opens a phantom column and breaks the row.
195
+ it.effect("escapes a pipe inside a table cell exactly once", () =>
196
+ Effect.gen(function*() {
197
+ const md = yield* roundTrip("| A |\n| --- |\n| a\\|b |\n")
198
+ expect(md).toContain("a\\|b")
199
+ expect(md).not.toContain("a\\\\|b")
200
+ }).pipe(Effect.provide(TestLayer)))
201
+
202
+ // Regression: a code fence *quoting* the placeholder syntax got structured
203
+ // nodes injected into the codeBlock, failing outgoing schema validation.
204
+ it.effect("does not expand placeholder-looking text inside a code fence", () =>
205
+ Effect.gen(function*() {
206
+ const fence = "```html\n" +
207
+ `<span class="adf-status" data-color="blue">X</span>\n` +
208
+ `<!-- adf:inlineExtension key=k type=t -->\n` +
209
+ "```\n"
210
+ const md = yield* roundTrip(fence)
211
+ expect(md).toContain(`<span class="adf-status" data-color="blue">X</span>`)
212
+ expect(md).toContain(`<!-- adf:inlineExtension key=k type=t -->`)
213
+ expect(md).toContain("```html")
214
+ }).pipe(Effect.provide(TestLayer)))
215
+
216
+ // Regression: the inline twin of the fence case — a code *span* quoting a
217
+ // placeholder was replaced by a real status node, silently dropping the
218
+ // quoted sample.
219
+ it.effect("does not expand placeholder-looking text inside a code span", () =>
220
+ Effect.gen(function*() {
221
+ const converter = yield* MarkdownConverter
222
+ const source = `Use \`<span class="adf-status" data-color="green">DONE</span>\` in docs\n`
223
+ const adf = JSON.parse(yield* converter.markdownToAdf(source)) as {
224
+ content: Array<{ content: Array<{ type: string }> }>
225
+ }
226
+ expect(adf.content[0]!.content.some((n) => n.type === "status")).toBe(false)
227
+ }).pipe(Effect.provide(TestLayer)))
228
+
229
+ // Regression: '# x' / '> x' / '+ x' paragraph text became real headings,
230
+ // quotes, and lists after the ESCAPE_RE narrowing dropped those characters.
231
+ it.effect("keeps line-start block markers in paragraph text as text", () =>
232
+ Effect.gen(function*() {
233
+ const converter = yield* MarkdownConverter
234
+ const paragraph = (text: string) => ({ type: "paragraph", content: [{ type: "text", text }] })
235
+ const md = yield* converter.adfToMarkdown(JSON.stringify({
236
+ version: 1,
237
+ type: "doc",
238
+ content: [paragraph("# not a heading"), paragraph("> not a quote"), paragraph("+ not a list")]
239
+ }))
240
+ const adfOut = JSON.parse(yield* converter.markdownToAdf(md)) as {
241
+ content: Array<{ type: string }>
242
+ }
243
+ expect(adfOut.content.map((n) => n.type)).toEqual(["paragraph", "paragraph", "paragraph"])
244
+ }).pipe(Effect.provide(TestLayer)))
245
+
246
+ it.effect("keeps the bodied kind for an empty-body bodied extension", () =>
247
+ Effect.gen(function*() {
248
+ const converter = yield* MarkdownConverter
249
+ const attrs = { extensionKey: "excerpt", extensionType: "com.atlassian.confluence.macro.core" }
250
+ const md = yield* converter.adfToMarkdown(JSON.stringify({
251
+ version: 1,
252
+ type: "doc",
253
+ content: [{ type: "bodiedExtension", attrs, content: [{ type: "paragraph", content: [] }] }]
254
+ }))
255
+ const adfOut = JSON.parse(yield* converter.markdownToAdf(md)) as {
256
+ content: Array<{ type: string }>
257
+ }
258
+ expect(adfOut.content[0]!.type).toBe("bodiedExtension")
259
+ }).pipe(Effect.provide(TestLayer)))
260
+
261
+ it.effect("preserves a mention's accountId through round-trip", () =>
262
+ Effect.gen(function*() {
263
+ const md = yield* roundTrip(`[@Andrey Konopkov](confluence-mention://557057%3Aabc-123)\n`)
264
+ expect(md).toContain("[@Andrey Konopkov](confluence-mention://557057%3Aabc-123)")
265
+ }).pipe(Effect.provide(TestLayer)))
266
+ })
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2025 knpkv
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
@@ -1,108 +0,0 @@
1
- /**
2
- * Error types for schema-based conversion.
3
- *
4
- * @module
5
- */
6
- import * as Data from "effect/Data"
7
-
8
- /**
9
- * Error thrown when parsing HTML or Markdown fails.
10
- *
11
- * @example
12
- * ```typescript
13
- * import { Effect } from "effect"
14
- * import { ParseError } from "@knpkv/confluence-to-markdown/SchemaConverterError"
15
- *
16
- * Effect.gen(function* () {
17
- * // ... parsing operation
18
- * }).pipe(
19
- * Effect.catchTag("ParseError", (error) =>
20
- * Effect.sync(() => console.error(`Parse error: ${error.message}`))
21
- * )
22
- * )
23
- * ```
24
- *
25
- * @category Errors
26
- */
27
- export class ParseError extends Data.TaggedError("ParseError")<{
28
- readonly source: "confluence" | "markdown"
29
- readonly message: string
30
- readonly position?: { readonly line: number; readonly column: number }
31
- readonly rawContent?: string
32
- }> {}
33
-
34
- /**
35
- * Error thrown when serializing AST to HTML or Markdown fails.
36
- *
37
- * @example
38
- * ```typescript
39
- * import { Effect } from "effect"
40
- * import { SerializeError } from "@knpkv/confluence-to-markdown/SchemaConverterError"
41
- *
42
- * Effect.gen(function* () {
43
- * // ... serialization operation
44
- * }).pipe(
45
- * Effect.catchTag("SerializeError", (error) =>
46
- * Effect.sync(() => console.error(`Serialize error: ${error.message}`))
47
- * )
48
- * )
49
- * ```
50
- *
51
- * @category Errors
52
- */
53
- export class SerializeError extends Data.TaggedError("SerializeError")<{
54
- readonly target: "confluence" | "markdown"
55
- readonly nodeType: string
56
- readonly message: string
57
- }> {}
58
-
59
- /**
60
- * Error thrown when migrating between schema versions fails.
61
- *
62
- * @example
63
- * ```typescript
64
- * import { Effect } from "effect"
65
- * import { MigrationError } from "@knpkv/confluence-to-markdown/SchemaConverterError"
66
- *
67
- * Effect.gen(function* () {
68
- * // ... migration operation
69
- * }).pipe(
70
- * Effect.catchTag("MigrationError", (error) =>
71
- * Effect.sync(() =>
72
- * console.error(`Migration error: ${error.nodeType} v${error.fromVersion} -> v${error.toVersion}`)
73
- * )
74
- * )
75
- * )
76
- * ```
77
- *
78
- * @category Errors
79
- */
80
- export class MigrationError extends Data.TaggedError("MigrationError")<{
81
- readonly nodeType: string
82
- readonly fromVersion: number
83
- readonly toVersion: number
84
- readonly message: string
85
- }> {}
86
-
87
- /**
88
- * Union of all schema converter errors.
89
- *
90
- * @category Errors
91
- */
92
- export type SchemaConverterError = ParseError | SerializeError | MigrationError
93
-
94
- /**
95
- * Type guard to check if error is a SchemaConverterError.
96
- *
97
- * @param error - The error to check
98
- * @returns True if error is a SchemaConverterError
99
- *
100
- * @category Utilities
101
- */
102
- export const isSchemaConverterError = (error: unknown): error is SchemaConverterError =>
103
- typeof error === "object" &&
104
- error !== null &&
105
- "_tag" in error &&
106
- ["ParseError", "SerializeError", "MigrationError"].includes(
107
- (error as { _tag: string })._tag
108
- )