@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.
- package/CHANGELOG.md +35 -0
- package/README.md +45 -10
- package/dist/ConfluenceAuth.d.ts.map +1 -1
- package/dist/ConfluenceAuth.js +12 -22
- package/dist/ConfluenceAuth.js.map +1 -1
- package/dist/ConfluenceClient.d.ts +13 -3
- package/dist/ConfluenceClient.d.ts.map +1 -1
- package/dist/ConfluenceClient.js +34 -70
- package/dist/ConfluenceClient.js.map +1 -1
- package/dist/ConfluenceError.d.ts +12 -12
- package/dist/GitError.d.ts +5 -5
- package/dist/GitService.d.ts.map +1 -1
- package/dist/GitService.js +0 -3
- package/dist/GitService.js.map +1 -1
- package/dist/SchemaConverterError.d.ts +3 -3
- package/dist/parsers/preprocessing/ConfluencePreprocessing.d.ts +23 -0
- package/dist/parsers/preprocessing/ConfluencePreprocessing.d.ts.map +1 -0
- package/dist/parsers/preprocessing/ConfluencePreprocessing.js +323 -0
- package/dist/parsers/preprocessing/ConfluencePreprocessing.js.map +1 -0
- package/dist/parsers/preprocessing/index.d.ts +7 -0
- package/dist/parsers/preprocessing/index.d.ts.map +1 -0
- package/dist/parsers/preprocessing/index.js +7 -0
- package/dist/parsers/preprocessing/index.js.map +1 -0
- package/dist/schemas/preprocessing/ConfluencePreprocessor.d.ts +29 -0
- package/dist/schemas/preprocessing/ConfluencePreprocessor.d.ts.map +1 -1
- package/dist/schemas/preprocessing/ConfluencePreprocessor.js +3 -5
- package/dist/schemas/preprocessing/ConfluencePreprocessor.js.map +1 -1
- package/package.json +35 -26
- package/src/AdfPlaceholders.ts +266 -0
- package/src/AdfSchemaValidator.ts +67 -0
- package/src/AdfWalker.ts +511 -0
- package/src/AtlaskitTransformers.ts +72 -0
- package/src/ConfluenceClient.ts +4 -4
- package/src/ConfluenceError.ts +65 -3
- package/src/MarkdownConverter.ts +106 -139
- package/src/Schemas.ts +4 -4
- package/src/SyncEngine.ts +130 -83
- package/src/atlaskit-adf-schema.d.ts +3 -0
- package/src/commands/clone.ts +8 -1
- package/src/commands/layers.ts +11 -4
- package/src/index.ts +3 -18
- package/test/AdfPlaceholders.test.ts +295 -0
- package/test/AdfSchemaValidator.test.ts +34 -0
- package/test/AdfWalker.test.ts +530 -0
- package/test/AtlaskitTransformers.test.ts +25 -0
- package/test/MarkdownConverter.test.ts +120 -105
- package/test/RoundTrip.test.ts +266 -0
- package/LICENSE +0 -21
- package/src/SchemaConverterError.ts +0 -108
- package/src/ast/BlockNode.ts +0 -425
- package/src/ast/Document.ts +0 -90
- package/src/ast/InlineNode.ts +0 -323
- package/src/ast/MacroNode.ts +0 -245
- package/src/ast/index.ts +0 -83
- package/src/parsers/ConfluenceParser.ts +0 -950
- package/src/parsers/MarkdownParser.ts +0 -1198
- package/src/parsers/index.ts +0 -8
- package/src/schemas/ConfluenceSchema.ts +0 -56
- package/src/schemas/ConversionSchema.ts +0 -318
- package/src/schemas/MarkdownSchema.ts +0 -56
- package/src/schemas/hast/HastFromHtml.ts +0 -153
- package/src/schemas/hast/HastSchema.ts +0 -274
- package/src/schemas/hast/index.ts +0 -35
- package/src/schemas/index.ts +0 -20
- package/src/schemas/mdast/MdastFromMarkdown.ts +0 -118
- package/src/schemas/mdast/MdastSchema.ts +0 -566
- package/src/schemas/mdast/index.ts +0 -59
- package/src/schemas/mdast/mdastToString.ts +0 -102
- package/src/schemas/nodes/block/BlockSchema.ts +0 -773
- package/src/schemas/nodes/block/index.ts +0 -13
- package/src/schemas/nodes/index.ts +0 -20
- package/src/schemas/nodes/inline/InlineSchema.ts +0 -523
- package/src/schemas/nodes/inline/index.ts +0 -14
- package/src/schemas/nodes/macro/MacroSchema.ts +0 -226
- package/src/schemas/nodes/macro/index.ts +0 -6
- package/src/schemas/preprocessing/ConfluencePreprocessor.ts +0 -446
- package/src/schemas/preprocessing/index.ts +0 -8
- package/src/serializers/ConfluenceSerializer.ts +0 -717
- package/src/serializers/MarkdownSerializer.ts +0 -493
- package/src/serializers/index.ts +0 -8
- package/test/ast/BlockNode.test.ts +0 -265
- package/test/ast/Document.test.ts +0 -126
- package/test/ast/InlineNode.test.ts +0 -161
- package/test/fixtures/integration-test.html.fixture +0 -103
- package/test/fixtures/integration-test.md.expected +0 -257
- package/test/parsers/ConfluenceParser.test.ts +0 -283
- package/test/schemas/ConfluencePreprocessor.test.ts +0 -180
- package/test/schemas/ConversionSchema.test.ts +0 -159
- package/test/schemas/HastSchema.test.ts +0 -138
- package/test/schemas/MdastSchema.test.ts +0 -145
- package/test/schemas/nodes/block/BlockSchema.test.ts +0 -173
- package/test/schemas/nodes/inline/InlineSchema.test.ts +0 -198
- 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
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
26
|
+
it.effect("converts a paragraph with marks", () =>
|
|
44
27
|
Effect.gen(function*() {
|
|
45
28
|
const converter = yield* MarkdownConverter
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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("
|
|
42
|
+
it.effect("converts a bullet list", () =>
|
|
53
43
|
Effect.gen(function*() {
|
|
54
44
|
const converter = yield* MarkdownConverter
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
64
|
+
it.effect("converts a code block with language", () =>
|
|
63
65
|
Effect.gen(function*() {
|
|
64
66
|
const converter = yield* MarkdownConverter
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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("
|
|
92
|
+
it.effect("fails with ConversionError on invalid JSON", () =>
|
|
83
93
|
Effect.gen(function*() {
|
|
84
94
|
const converter = yield* MarkdownConverter
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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("
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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("
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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("
|
|
111
|
-
it.effect("
|
|
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
|
|
124
|
-
const
|
|
125
|
-
expect(
|
|
126
|
-
expect(
|
|
127
|
-
expect(
|
|
128
|
-
|
|
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("
|
|
147
|
+
it.effect("round-trips a heading + paragraph through both directions", () =>
|
|
132
148
|
Effect.gen(function*() {
|
|
133
149
|
const converter = yield* MarkdownConverter
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
expect(
|
|
138
|
-
expect(
|
|
139
|
-
|
|
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
|
-
)
|