@knpkv/confluence-to-markdown 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +45 -10
  3. package/dist/ConfluenceAuth.d.ts.map +1 -1
  4. package/dist/ConfluenceAuth.js +12 -22
  5. package/dist/ConfluenceAuth.js.map +1 -1
  6. package/dist/ConfluenceClient.d.ts +13 -3
  7. package/dist/ConfluenceClient.d.ts.map +1 -1
  8. package/dist/ConfluenceClient.js +34 -70
  9. package/dist/ConfluenceClient.js.map +1 -1
  10. package/dist/ConfluenceError.d.ts +12 -12
  11. package/dist/GitError.d.ts +5 -5
  12. package/dist/GitService.d.ts.map +1 -1
  13. package/dist/GitService.js +0 -3
  14. package/dist/GitService.js.map +1 -1
  15. package/dist/SchemaConverterError.d.ts +3 -3
  16. package/dist/parsers/preprocessing/ConfluencePreprocessing.d.ts +23 -0
  17. package/dist/parsers/preprocessing/ConfluencePreprocessing.d.ts.map +1 -0
  18. package/dist/parsers/preprocessing/ConfluencePreprocessing.js +323 -0
  19. package/dist/parsers/preprocessing/ConfluencePreprocessing.js.map +1 -0
  20. package/dist/parsers/preprocessing/index.d.ts +7 -0
  21. package/dist/parsers/preprocessing/index.d.ts.map +1 -0
  22. package/dist/parsers/preprocessing/index.js +7 -0
  23. package/dist/parsers/preprocessing/index.js.map +1 -0
  24. package/dist/schemas/preprocessing/ConfluencePreprocessor.d.ts +29 -0
  25. package/dist/schemas/preprocessing/ConfluencePreprocessor.d.ts.map +1 -1
  26. package/dist/schemas/preprocessing/ConfluencePreprocessor.js +3 -5
  27. package/dist/schemas/preprocessing/ConfluencePreprocessor.js.map +1 -1
  28. package/package.json +35 -26
  29. package/src/AdfPlaceholders.ts +266 -0
  30. package/src/AdfSchemaValidator.ts +67 -0
  31. package/src/AdfWalker.ts +511 -0
  32. package/src/AtlaskitTransformers.ts +72 -0
  33. package/src/ConfluenceClient.ts +4 -4
  34. package/src/ConfluenceError.ts +65 -3
  35. package/src/MarkdownConverter.ts +106 -139
  36. package/src/Schemas.ts +4 -4
  37. package/src/SyncEngine.ts +130 -83
  38. package/src/atlaskit-adf-schema.d.ts +3 -0
  39. package/src/commands/clone.ts +8 -1
  40. package/src/commands/layers.ts +11 -4
  41. package/src/index.ts +3 -18
  42. package/test/AdfPlaceholders.test.ts +295 -0
  43. package/test/AdfSchemaValidator.test.ts +34 -0
  44. package/test/AdfWalker.test.ts +530 -0
  45. package/test/AtlaskitTransformers.test.ts +25 -0
  46. package/test/MarkdownConverter.test.ts +120 -105
  47. package/test/RoundTrip.test.ts +266 -0
  48. package/LICENSE +0 -21
  49. package/src/SchemaConverterError.ts +0 -108
  50. package/src/ast/BlockNode.ts +0 -425
  51. package/src/ast/Document.ts +0 -90
  52. package/src/ast/InlineNode.ts +0 -323
  53. package/src/ast/MacroNode.ts +0 -245
  54. package/src/ast/index.ts +0 -83
  55. package/src/parsers/ConfluenceParser.ts +0 -950
  56. package/src/parsers/MarkdownParser.ts +0 -1198
  57. package/src/parsers/index.ts +0 -8
  58. package/src/schemas/ConfluenceSchema.ts +0 -56
  59. package/src/schemas/ConversionSchema.ts +0 -318
  60. package/src/schemas/MarkdownSchema.ts +0 -56
  61. package/src/schemas/hast/HastFromHtml.ts +0 -153
  62. package/src/schemas/hast/HastSchema.ts +0 -274
  63. package/src/schemas/hast/index.ts +0 -35
  64. package/src/schemas/index.ts +0 -20
  65. package/src/schemas/mdast/MdastFromMarkdown.ts +0 -118
  66. package/src/schemas/mdast/MdastSchema.ts +0 -566
  67. package/src/schemas/mdast/index.ts +0 -59
  68. package/src/schemas/mdast/mdastToString.ts +0 -102
  69. package/src/schemas/nodes/block/BlockSchema.ts +0 -773
  70. package/src/schemas/nodes/block/index.ts +0 -13
  71. package/src/schemas/nodes/index.ts +0 -20
  72. package/src/schemas/nodes/inline/InlineSchema.ts +0 -523
  73. package/src/schemas/nodes/inline/index.ts +0 -14
  74. package/src/schemas/nodes/macro/MacroSchema.ts +0 -226
  75. package/src/schemas/nodes/macro/index.ts +0 -6
  76. package/src/schemas/preprocessing/ConfluencePreprocessor.ts +0 -446
  77. package/src/schemas/preprocessing/index.ts +0 -8
  78. package/src/serializers/ConfluenceSerializer.ts +0 -717
  79. package/src/serializers/MarkdownSerializer.ts +0 -493
  80. package/src/serializers/index.ts +0 -8
  81. package/test/ast/BlockNode.test.ts +0 -265
  82. package/test/ast/Document.test.ts +0 -126
  83. package/test/ast/InlineNode.test.ts +0 -161
  84. package/test/fixtures/integration-test.html.fixture +0 -103
  85. package/test/fixtures/integration-test.md.expected +0 -257
  86. package/test/parsers/ConfluenceParser.test.ts +0 -283
  87. package/test/schemas/ConfluencePreprocessor.test.ts +0 -180
  88. package/test/schemas/ConversionSchema.test.ts +0 -159
  89. package/test/schemas/HastSchema.test.ts +0 -138
  90. package/test/schemas/MdastSchema.test.ts +0 -145
  91. package/test/schemas/nodes/block/BlockSchema.test.ts +0 -173
  92. package/test/schemas/nodes/inline/InlineSchema.test.ts +0 -198
  93. package/test/schemas/nodes/macro/MacroSchema.test.ts +0 -142
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Reverse the AdfWalker's placeholder syntax back into proper ADF nodes
3
+ * after the @atlaskit markdown transformer has run.
4
+ *
5
+ * The walker emits Confluence-only nodes (status, extension, inlineExtension)
6
+ * as HTML/comment placeholders so they survive a pull. The markdown transformer
7
+ * has no concept of these nodes, so on push it parses the placeholders as
8
+ * plain text — Confluence then renders the literal HTML/comment. This module
9
+ * walks the produced ADF and rewrites those text patterns into the structured
10
+ * nodes the editor expects.
11
+ *
12
+ * Patterns recognized (must match the AdfWalker emission exactly):
13
+ * - `<span class="adf-status" data-color="COLOR">TEXT</span>`
14
+ * - `<!-- adf:extension key=KEY type=TYPE attrs=BASE64 -->` (block, when
15
+ * the whole paragraph is just this comment; `attrs` is base64 JSON of
16
+ * the node's full attrs — parameters, localId, layout — and wins over
17
+ * the readable key/type parts; key/type-only is the legacy form)
18
+ * - `<!-- adf:bodiedExtension … --> BODY <!-- adf:/bodiedExtension -->`
19
+ * (the sibling blocks between the markers become the extension's body)
20
+ * - `<!-- adf:inlineExtension key=KEY type=TYPE attrs=BASE64 -->` (inline)
21
+ * - `[@Name](confluence-mention://ACCOUNT_ID)` (link mark with a
22
+ * custom scheme — the only way to round-trip mention accountIds)
23
+ *
24
+ * @module
25
+ */
26
+
27
+ import * as Either from "effect/Either"
28
+ import * as Schema from "effect/Schema"
29
+
30
+ interface AdfNode {
31
+ readonly type: string
32
+ readonly attrs?: Record<string, unknown>
33
+ readonly content?: ReadonlyArray<AdfNode>
34
+ readonly text?: string
35
+ readonly marks?: ReadonlyArray<AdfNode>
36
+ }
37
+
38
+ const STATUS_RE = /<span class="adf-status"\s+data-color="([^"]+)">([^<]*)<\/span>/g
39
+ const INLINE_EXTENSION_RE =
40
+ /<!--\s*adf:inlineExtension(?:\s+key=(\S+?))?(?:\s+type=(\S+?))?(?:\s+attrs=([A-Za-z0-9+/=]+))?\s*-->/g
41
+ const COMBINED_INLINE_RE = new RegExp(`${STATUS_RE.source}|${INLINE_EXTENSION_RE.source}`, "g")
42
+
43
+ const BLOCK_EXTENSION_RE =
44
+ /^\s*<!--\s*adf:(extension|bodiedExtension)(?:\s+key=(\S+?))?(?:\s+type=(\S+?))?(?:\s+attrs=([A-Za-z0-9+/=]+))?\s*-->\s*$/
45
+ const BODIED_EXTENSION_END_RE = /^\s*<!--\s*adf:\/bodiedExtension\s*-->\s*$/
46
+
47
+ const textNode = (text: string, marks: ReadonlyArray<AdfNode> | undefined): AdfNode =>
48
+ marks && marks.length > 0 ? { type: "text", text, marks } : { type: "text", text }
49
+
50
+ // Code-marked text is a *quotation* of placeholder syntax, not a placeholder
51
+ // (the walker never emits placeholders with a code mark) — expanding it would
52
+ // corrupt documentation that demonstrates the syntax.
53
+ const hasCodeMark = (n: AdfNode): boolean => (n.marks ?? []).some((m) => m.type === "code")
54
+
55
+ // Parents whose content model permits bodiedExtension per @atlaskit/adf-schema
56
+ // (blockquote/listItem/tableCell allow plain extension but NOT bodied — emitting
57
+ // one there fails outgoing validation and the whole push errors out).
58
+ const BODIED_EXTENSION_PARENTS = new Set(["doc", "layoutColumn"])
59
+
60
+ // Web APIs only (atob/TextDecoder) — this module is a standalone subpath
61
+ // export and must not assume Node, mirroring the walker's encoder.
62
+ const fromBase64 = (b64: string): string => {
63
+ const bin = atob(b64)
64
+ return new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)))
65
+ }
66
+
67
+ // JSON string → free-form attrs record; rejects null/arrays/primitives.
68
+ const AttrsBlob = Schema.parseJson(Schema.Record({ key: Schema.String, value: Schema.Unknown }))
69
+ const decodeAttrsBlob = Schema.decodeUnknownEither(AttrsBlob)
70
+
71
+ const decodeAttrs = (b64: string | undefined): Record<string, unknown> | null => {
72
+ if (!b64) return null
73
+ try {
74
+ const decoded = decodeAttrsBlob(fromBase64(b64))
75
+ return Either.isRight(decoded) ? decoded.right : null
76
+ } catch {
77
+ // Invalid base64 (hand-edited file?) — fall back to the readable key/type.
78
+ return null
79
+ }
80
+ }
81
+
82
+ const buildExtensionAttrs = (
83
+ key: string | undefined,
84
+ type: string | undefined,
85
+ attrsB64: string | undefined
86
+ ): Record<string, unknown> => {
87
+ const decoded = decodeAttrs(attrsB64)
88
+ if (decoded) return decoded
89
+ const attrs: Record<string, unknown> = {}
90
+ if (key) attrs.extensionKey = key
91
+ if (type) attrs.extensionType = type
92
+ return attrs
93
+ }
94
+
95
+ /** Split a text node into a sequence of text + status + inlineExtension nodes. */
96
+ const expandInlineText = (
97
+ text: string,
98
+ marks: ReadonlyArray<AdfNode> | undefined
99
+ ): ReadonlyArray<AdfNode> => {
100
+ // Reset lastIndex; sticky regexes are stateful across calls.
101
+ const re = new RegExp(COMBINED_INLINE_RE.source, "g")
102
+ const out: Array<AdfNode> = []
103
+ let lastIndex = 0
104
+ let match: RegExpExecArray | null
105
+
106
+ while ((match = re.exec(text)) !== null) {
107
+ if (match.index > lastIndex) {
108
+ out.push(textNode(text.slice(lastIndex, match.index), marks))
109
+ }
110
+ // Status capture groups: 1=color, 2=text. InlineExtension: 3=key, 4=type, 5=attrs.
111
+ if (match[1] !== undefined) {
112
+ out.push({ type: "status", attrs: { text: match[2] ?? "", color: match[1] } })
113
+ } else {
114
+ out.push({ type: "inlineExtension", attrs: buildExtensionAttrs(match[3], match[4], match[5]) })
115
+ }
116
+ lastIndex = match.index + match[0].length
117
+ }
118
+
119
+ if (lastIndex === 0) return [textNode(text, marks)]
120
+ if (lastIndex < text.length) out.push(textNode(text.slice(lastIndex), marks))
121
+ return out
122
+ }
123
+
124
+ const MENTION_SCHEME = "confluence-mention://"
125
+
126
+ /**
127
+ * If the text node carries a `confluence-mention://` link mark, return the
128
+ * corresponding mention node — otherwise null.
129
+ */
130
+ const tryParseMentionTextNode = (n: AdfNode): AdfNode | null => {
131
+ if (!n.text || !n.marks) return null
132
+ const link = n.marks.find((m) => m.type === "link")
133
+ if (!link) return null
134
+ const href = link.attrs?.["href"]
135
+ if (typeof href !== "string" || !href.startsWith(MENTION_SCHEME)) return null
136
+ let id: string
137
+ try {
138
+ id = decodeURIComponent(href.slice(MENTION_SCHEME.length))
139
+ } catch {
140
+ // Malformed encoding — leave the link alone rather than crashing the push.
141
+ return null
142
+ }
143
+ return { type: "mention", attrs: { id, text: n.text } }
144
+ }
145
+
146
+ interface BlockExtensionMarker {
147
+ readonly kind: "extension" | "bodiedExtension"
148
+ readonly attrs: Record<string, unknown>
149
+ }
150
+
151
+ /**
152
+ * If the paragraph's only content is a single text node holding a block-extension
153
+ * comment, return the marker's kind and reconstructed attrs — otherwise null.
154
+ */
155
+ const soleTextChild = (node: AdfNode): AdfNode | null => {
156
+ if (node.type !== "paragraph") return null
157
+ const content = node.content ?? []
158
+ if (content.length !== 1) return null
159
+ const child = content[0]
160
+ if (!child || child.type !== "text" || !child.text || hasCodeMark(child)) return null
161
+ return child
162
+ }
163
+
164
+ const parseBlockExtensionParagraph = (node: AdfNode): BlockExtensionMarker | null => {
165
+ const child = soleTextChild(node)
166
+ if (!child || !child.text) return null
167
+ const match = BLOCK_EXTENSION_RE.exec(child.text)
168
+ if (!match) return null
169
+ const [, kind, key, type, attrsB64] = match
170
+ return {
171
+ kind: kind === "bodiedExtension" ? "bodiedExtension" : "extension",
172
+ attrs: buildExtensionAttrs(key, type, attrsB64)
173
+ }
174
+ }
175
+
176
+ const isBodiedExtensionEnd = (node: AdfNode): boolean => {
177
+ const child = soleTextChild(node)
178
+ return child !== null && typeof child.text === "string" && BODIED_EXTENSION_END_RE.test(child.text)
179
+ }
180
+
181
+ /**
182
+ * Replace block-extension marker paragraphs among `children`. A bare
183
+ * `extension` marker becomes an extension node; a `bodiedExtension` marker
184
+ * swallows every sibling up to its `adf:/bodiedExtension` end marker as the
185
+ * extension's body.
186
+ *
187
+ * Pairing rules, in order of defence:
188
+ * - the forward scan stops at the next bodied *open* marker, so an unpaired
189
+ * legacy/hand-edited open cannot steal a later macro's end marker and
190
+ * swallow unrelated content in between;
191
+ * - an open with no end marker is downgraded to a plain extension (macro
192
+ * identity and configuration kept, body left in place as siblings);
193
+ * - an open/end pair with nothing in between keeps its bodied kind via a
194
+ * stub empty paragraph (the schema requires non-empty content);
195
+ * - parents whose content model forbids bodiedExtension (blockquote,
196
+ * listItem, …) get the downgrade too, or outgoing validation would fail;
197
+ * - stray end markers are dropped — they are this module's own syntax,
198
+ * never user content.
199
+ */
200
+ const groupBlockExtensions = (children: ReadonlyArray<AdfNode>, parentType: string): ReadonlyArray<AdfNode> => {
201
+ const allowBodied = BODIED_EXTENSION_PARENTS.has(parentType)
202
+ const out: Array<AdfNode> = []
203
+ for (let i = 0; i < children.length; i++) {
204
+ const child = children[i]
205
+ if (!child) continue
206
+ const marker = parseBlockExtensionParagraph(child)
207
+ if (!marker) {
208
+ if (!isBodiedExtensionEnd(child)) out.push(child)
209
+ continue
210
+ }
211
+ if (marker.kind === "extension") {
212
+ out.push({ type: "extension", attrs: marker.attrs })
213
+ continue
214
+ }
215
+ let end = -1
216
+ for (let j = i + 1; j < children.length; j++) {
217
+ if (isBodiedExtensionEnd(children[j]!)) {
218
+ end = j
219
+ break
220
+ }
221
+ if (parseBlockExtensionParagraph(children[j]!)?.kind === "bodiedExtension") break
222
+ }
223
+ if (end === -1 || !allowBodied) {
224
+ out.push({ type: "extension", attrs: marker.attrs })
225
+ continue
226
+ }
227
+ // Group recursively so an extension marker *inside* the body (a macro
228
+ // nested in a bodied macro) is also reverted, not left as literal text.
229
+ const body = groupBlockExtensions(children.slice(i + 1, end), "bodiedExtension")
230
+ out.push({
231
+ type: "bodiedExtension",
232
+ attrs: marker.attrs,
233
+ content: body.length > 0 ? body : [{ type: "paragraph", content: [] }]
234
+ })
235
+ i = end
236
+ }
237
+ return out
238
+ }
239
+
240
+ const transform = (node: AdfNode): AdfNode => {
241
+ // ADF codeBlock permits only plain text children — expanding placeholder-
242
+ // looking text inside one would inject schema-invalid nodes and corrupt
243
+ // code samples that merely *quote* the placeholder syntax.
244
+ if (node.type === "codeBlock") return node
245
+ if (!node.content) return node
246
+
247
+ const newContent: Array<AdfNode> = []
248
+ for (const child of node.content) {
249
+ if (child.type === "text" && child.text && !hasCodeMark(child)) {
250
+ const mention = tryParseMentionTextNode(child)
251
+ if (mention) {
252
+ newContent.push(mention)
253
+ } else {
254
+ for (const piece of expandInlineText(child.text, child.marks)) {
255
+ newContent.push(piece)
256
+ }
257
+ }
258
+ } else {
259
+ newContent.push(transform(child))
260
+ }
261
+ }
262
+ return { ...node, content: groupBlockExtensions(newContent, node.type) }
263
+ }
264
+
265
+ /** Walk the document tree and rewrite placeholder text into proper ADF nodes. */
266
+ export const revertPlaceholders = (doc: unknown): unknown => transform(doc as AdfNode)
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Runtime JSON-Schema validator for ADF documents.
3
+ *
4
+ * Wraps Ajv compiled against the canonical schema bundled in
5
+ * `@atlaskit/adf-schema` (`json-schema/v1/full.json`). Used on both
6
+ * directions of the conversion: incoming (after `JSON.parse`, before walking)
7
+ * and outgoing (after the @atlaskit transformer produces JSON, before
8
+ * `JSON.stringify`). The single project-wide cast `as DocNode` lives here,
9
+ * bridging Ajv's runtime predicate to the TypeScript types.
10
+ *
11
+ * @module
12
+ */
13
+ import type { DocNode } from "@atlaskit/adf-schema"
14
+ import adfJsonSchema from "@atlaskit/adf-schema/json-schema/v1/full.json" with { type: "json" }
15
+ import Ajv from "ajv-draft-04"
16
+ import * as Context from "effect/Context"
17
+ import * as Effect from "effect/Effect"
18
+ import * as Layer from "effect/Layer"
19
+ import { AdfSchemaError, type AdfSchemaIssue } from "./ConfluenceError.js"
20
+
21
+ const ajv = new Ajv({ strict: false, allErrors: true })
22
+ const validate = ajv.compile(adfJsonSchema as object)
23
+
24
+ /**
25
+ * Effect service that runtime-validates ADF documents against the canonical
26
+ * @atlaskit/adf-schema JSON Schema and narrows the success type to `DocNode`.
27
+ *
28
+ * @category Service
29
+ */
30
+ export class AdfSchemaValidator extends Context.Tag(
31
+ "@knpkv/confluence-to-markdown/AdfSchemaValidator"
32
+ )<
33
+ AdfSchemaValidator,
34
+ {
35
+ readonly check: (
36
+ doc: unknown,
37
+ direction: "incoming" | "outgoing"
38
+ ) => Effect.Effect<DocNode, AdfSchemaError>
39
+ }
40
+ >() {}
41
+
42
+ /**
43
+ * Live Layer for `AdfSchemaValidator`. The Ajv validator is compiled once at
44
+ * module load.
45
+ *
46
+ * @category Layers
47
+ */
48
+ export const layer: Layer.Layer<AdfSchemaValidator> = Layer.succeed(
49
+ AdfSchemaValidator,
50
+ AdfSchemaValidator.of({
51
+ check: (doc, direction) =>
52
+ validate(doc)
53
+ ? Effect.succeed(doc as DocNode)
54
+ : Effect.fail(
55
+ new AdfSchemaError({
56
+ direction,
57
+ issues: (validate.errors ?? []).map((e): AdfSchemaIssue => ({
58
+ instancePath: e.instancePath,
59
+ schemaPath: e.schemaPath,
60
+ keyword: e.keyword,
61
+ params: e.params,
62
+ ...(e.message !== undefined ? { message: e.message } : {})
63
+ }))
64
+ })
65
+ )
66
+ })
67
+ )