@knpkv/confluence-to-markdown 0.4.2 → 0.5.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 +13 -0
- package/dist/ast/BlockNode.d.ts +33 -48
- package/dist/ast/BlockNode.d.ts.map +1 -1
- package/dist/ast/BlockNode.js +2 -11
- package/dist/ast/BlockNode.js.map +1 -1
- package/dist/ast/Document.d.ts +2 -30
- package/dist/ast/Document.d.ts.map +1 -1
- package/dist/parsers/ConfluenceParser.d.ts.map +1 -1
- package/dist/parsers/ConfluenceParser.js +12 -7
- package/dist/parsers/ConfluenceParser.js.map +1 -1
- package/dist/parsers/MarkdownParser.js +117 -8
- package/dist/parsers/MarkdownParser.js.map +1 -1
- package/dist/schemas/preprocessing/ConfluencePreprocessor.js +10 -2
- package/dist/schemas/preprocessing/ConfluencePreprocessor.js.map +1 -1
- package/dist/serializers/ConfluenceSerializer.js +9 -0
- package/dist/serializers/ConfluenceSerializer.js.map +1 -1
- package/dist/serializers/MarkdownSerializer.js +49 -9
- package/dist/serializers/MarkdownSerializer.js.map +1 -1
- package/package.json +1 -1
- package/src/ast/BlockNode.ts +65 -21
- package/src/parsers/ConfluenceParser.ts +17 -11
- package/src/parsers/MarkdownParser.ts +156 -16
- package/src/schemas/preprocessing/ConfluencePreprocessor.ts +11 -2
- package/src/serializers/ConfluenceSerializer.ts +21 -1
- package/src/serializers/MarkdownSerializer.ts +60 -10
- package/test/parsers/ConfluenceParser.test.ts +169 -0
package/src/ast/BlockNode.ts
CHANGED
|
@@ -305,28 +305,79 @@ export type BlockQuote = Schema.Schema.Type<typeof BlockQuote>
|
|
|
305
305
|
/**
|
|
306
306
|
* List item with nested block content.
|
|
307
307
|
*
|
|
308
|
-
* @
|
|
309
|
-
*/
|
|
310
|
-
export const ListItem = Schema.Struct({
|
|
311
|
-
_tag: Schema.Literal("ListItem"),
|
|
312
|
-
checked: Schema.optional(Schema.Boolean),
|
|
313
|
-
children: Schema.Array(SimpleBlockNode),
|
|
314
|
-
rawConfluence: RawConfluence
|
|
315
|
-
})
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Type for ListItem.
|
|
308
|
+
* Children may include any simple block as well as nested {@link List} elements.
|
|
319
309
|
*
|
|
320
|
-
* @category
|
|
310
|
+
* @category BlockNode
|
|
321
311
|
*/
|
|
322
|
-
export
|
|
312
|
+
export interface ListItem {
|
|
313
|
+
readonly _tag: "ListItem"
|
|
314
|
+
readonly checked?: boolean | undefined
|
|
315
|
+
readonly children: ReadonlyArray<
|
|
316
|
+
Heading | Paragraph | CodeBlock | ThematicBreak | Image | Table | UnsupportedBlock | List
|
|
317
|
+
>
|
|
318
|
+
readonly rawConfluence?: string | undefined
|
|
319
|
+
}
|
|
323
320
|
|
|
324
321
|
/**
|
|
325
322
|
* List element (ordered or unordered).
|
|
326
323
|
*
|
|
327
324
|
* @category BlockNode
|
|
328
325
|
*/
|
|
329
|
-
export
|
|
326
|
+
export interface List {
|
|
327
|
+
readonly _tag: "List"
|
|
328
|
+
readonly version: number
|
|
329
|
+
readonly ordered: boolean
|
|
330
|
+
readonly start?: number | undefined
|
|
331
|
+
readonly children: ReadonlyArray<ListItem>
|
|
332
|
+
readonly rawConfluence?: string | undefined
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Encoded shapes (input to decode): `version` is optional via SchemaVersion's
|
|
336
|
+
// optionalWith({ default }), and the recursive cycle is List → ListItem → List.
|
|
337
|
+
export interface ListEncoded {
|
|
338
|
+
readonly _tag: "List"
|
|
339
|
+
readonly version?: number | undefined
|
|
340
|
+
readonly ordered: boolean
|
|
341
|
+
readonly start?: number | undefined
|
|
342
|
+
readonly children: ReadonlyArray<ListItemEncoded>
|
|
343
|
+
readonly rawConfluence?: string | undefined
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export interface ListItemEncoded {
|
|
347
|
+
readonly _tag: "ListItem"
|
|
348
|
+
readonly checked?: boolean | undefined
|
|
349
|
+
readonly children: ReadonlyArray<
|
|
350
|
+
| Schema.Schema.Encoded<typeof Heading>
|
|
351
|
+
| Schema.Schema.Encoded<typeof Paragraph>
|
|
352
|
+
| Schema.Schema.Encoded<typeof CodeBlock>
|
|
353
|
+
| Schema.Schema.Encoded<typeof ThematicBreak>
|
|
354
|
+
| Schema.Schema.Encoded<typeof Image>
|
|
355
|
+
| Schema.Schema.Encoded<typeof Table>
|
|
356
|
+
| Schema.Schema.Encoded<typeof UnsupportedBlock>
|
|
357
|
+
| ListEncoded
|
|
358
|
+
>
|
|
359
|
+
readonly rawConfluence?: string | undefined
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const ListItemChild = Schema.Union(
|
|
363
|
+
Heading,
|
|
364
|
+
Paragraph,
|
|
365
|
+
CodeBlock,
|
|
366
|
+
ThematicBreak,
|
|
367
|
+
Image,
|
|
368
|
+
Table,
|
|
369
|
+
UnsupportedBlock,
|
|
370
|
+
Schema.suspend((): Schema.Schema<List, ListEncoded> => List)
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
export const ListItem: Schema.Schema<ListItem, ListItemEncoded> = Schema.Struct({
|
|
374
|
+
_tag: Schema.Literal("ListItem"),
|
|
375
|
+
checked: Schema.optional(Schema.Boolean),
|
|
376
|
+
children: Schema.Array(ListItemChild),
|
|
377
|
+
rawConfluence: RawConfluence
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
export const List: Schema.Schema<List, ListEncoded> = Schema.Struct({
|
|
330
381
|
_tag: Schema.Literal("List"),
|
|
331
382
|
version: SchemaVersion,
|
|
332
383
|
ordered: Schema.Boolean,
|
|
@@ -335,13 +386,6 @@ export const List = Schema.Struct({
|
|
|
335
386
|
rawConfluence: RawConfluence
|
|
336
387
|
})
|
|
337
388
|
|
|
338
|
-
/**
|
|
339
|
-
* Type for List.
|
|
340
|
-
*
|
|
341
|
-
* @category Types
|
|
342
|
-
*/
|
|
343
|
-
export type List = Schema.Schema.Type<typeof List>
|
|
344
|
-
|
|
345
389
|
/**
|
|
346
390
|
* Task item with status for Confluence task lists.
|
|
347
391
|
*
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
CodeBlock,
|
|
12
12
|
Heading,
|
|
13
13
|
Image,
|
|
14
|
+
type List,
|
|
14
15
|
Paragraph,
|
|
15
16
|
Table,
|
|
16
17
|
TableCell,
|
|
@@ -722,12 +723,20 @@ const parseTableRow = (element: HastElement, isHeader: boolean): Effect.Effect<T
|
|
|
722
723
|
|
|
723
724
|
/**
|
|
724
725
|
* Parse cell content, unwrapping single <p> elements.
|
|
726
|
+
*
|
|
727
|
+
* Confluence's editor frequently emits empty `<p>` placeholders alongside the
|
|
728
|
+
* real content (e.g. `<p/><p>Must</p>` for a styled cell). Skip those so the
|
|
729
|
+
* single-real-paragraph case still hits the unwrap path.
|
|
725
730
|
*/
|
|
726
731
|
const parseCellContent = (children: Array<HastNode>): Effect.Effect<Array<InlineNode>, ParseError> =>
|
|
727
732
|
Effect.gen(function*() {
|
|
728
|
-
|
|
733
|
+
const isEmptyParagraph = (el: HastElement): boolean =>
|
|
734
|
+
el.tagName.toLowerCase() === "p" &&
|
|
735
|
+
el.children.every((c) => c.type === "text" && !(c as HastText).value.trim())
|
|
736
|
+
|
|
737
|
+
// Find actual element children (skip whitespace text and empty <p> placeholders)
|
|
729
738
|
const elementChildren = children.filter((c) => {
|
|
730
|
-
if (c.type === "element") return
|
|
739
|
+
if (c.type === "element") return !isEmptyParagraph(c as HastElement)
|
|
731
740
|
if (c.type === "text" && (c as HastText).value.trim()) return true
|
|
732
741
|
return false
|
|
733
742
|
})
|
|
@@ -744,8 +753,8 @@ const parseCellContent = (children: Array<HastNode>): Effect.Effect<Array<Inline
|
|
|
744
753
|
return yield* hastChildrenToInline(children)
|
|
745
754
|
})
|
|
746
755
|
|
|
747
|
-
// Type for simple blocks
|
|
748
|
-
type SimpleBlock = Heading | Paragraph | CodeBlock | ThematicBreak | Image | Table | UnsupportedBlock
|
|
756
|
+
// Type for blocks allowed inside list items: simple blocks plus nested Lists.
|
|
757
|
+
type SimpleBlock = Heading | Paragraph | CodeBlock | ThematicBreak | Image | Table | UnsupportedBlock | List
|
|
749
758
|
|
|
750
759
|
/**
|
|
751
760
|
* Parse task list element (preprocessed from ac:task-list).
|
|
@@ -865,7 +874,7 @@ const parseListItemContent = (
|
|
|
865
874
|
const el = child as HastElement
|
|
866
875
|
const tagName = el.tagName.toLowerCase()
|
|
867
876
|
if (tagName === "ul" || tagName === "ol") {
|
|
868
|
-
blocks.push(
|
|
877
|
+
blocks.push(yield* parseList(el, tagName === "ol"))
|
|
869
878
|
}
|
|
870
879
|
}
|
|
871
880
|
}
|
|
@@ -881,12 +890,9 @@ const parseListItemContent = (
|
|
|
881
890
|
if (tagName === "p") {
|
|
882
891
|
const inlineChildren = yield* hastChildrenToInline(el.children)
|
|
883
892
|
blocks.push(new Paragraph({ children: inlineChildren }))
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
blocks.push(new UnsupportedBlock({ rawHtml: hastToHtml(el), source: "confluence" }))
|
|
888
|
-
} // Other block elements
|
|
889
|
-
else if (tagName === "pre") {
|
|
893
|
+
} else if (tagName === "ul" || tagName === "ol") {
|
|
894
|
+
blocks.push(yield* parseList(el, tagName === "ol"))
|
|
895
|
+
} else if (tagName === "pre") {
|
|
890
896
|
const codeEl = el.children.find(
|
|
891
897
|
(c): c is HastElement => c.type === "element" && (c as HastElement).tagName === "code"
|
|
892
898
|
)
|
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
UnsupportedInline,
|
|
41
41
|
UserMention
|
|
42
42
|
} from "../ast/InlineNode.js"
|
|
43
|
-
import { type InfoPanel, PanelTypes, type TocMacro } from "../ast/MacroNode.js"
|
|
43
|
+
import { type ExpandMacro, type InfoPanel, PanelTypes, type TocMacro } from "../ast/MacroNode.js"
|
|
44
44
|
import { ParseError } from "../SchemaConverterError.js"
|
|
45
45
|
|
|
46
46
|
// Mdast types (inline to avoid dependency)
|
|
@@ -251,17 +251,70 @@ const preprocessContainers = (markdown: string): string => {
|
|
|
251
251
|
|
|
252
252
|
/**
|
|
253
253
|
* Convert mdast Root to document nodes.
|
|
254
|
+
*
|
|
255
|
+
* Recognises `<details><summary>title</summary>...body...</details>` HTML blocks
|
|
256
|
+
* and rebuilds them as ExpandMacro nodes so they round-trip back to Confluence.
|
|
254
257
|
*/
|
|
255
258
|
const mdastToDocumentNodes = (root: MdastRoot): Effect.Effect<Array<DocumentNode>, ParseError> =>
|
|
256
259
|
Effect.gen(function*() {
|
|
257
260
|
const nodes: Array<DocumentNode> = []
|
|
258
|
-
|
|
261
|
+
const children = root.children
|
|
262
|
+
let i = 0
|
|
263
|
+
while (i < children.length) {
|
|
264
|
+
const child = children[i]
|
|
265
|
+
if (!child) {
|
|
266
|
+
i++
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
if (child.type === "html") {
|
|
270
|
+
const detailsMatch = (child as MdastHtml).value.match(
|
|
271
|
+
/^<details(?:\s[^>]*)?>\s*<summary(?:\s[^>]*)?>([\s\S]*?)<\/summary>/
|
|
272
|
+
)
|
|
273
|
+
if (detailsMatch) {
|
|
274
|
+
const title = decodeHtmlEntities(detailsMatch[1] ?? "").trim()
|
|
275
|
+
let j = i + 1
|
|
276
|
+
while (j < children.length) {
|
|
277
|
+
const c = children[j]
|
|
278
|
+
if (c?.type === "html" && /<\/details>/.test((c as MdastHtml).value)) break
|
|
279
|
+
j++
|
|
280
|
+
}
|
|
281
|
+
const bodyChildren = children.slice(i + 1, j)
|
|
282
|
+
const bodyBlocks = yield* mdastChildrenToSimpleBlocks(bodyChildren)
|
|
283
|
+
nodes.push(
|
|
284
|
+
{
|
|
285
|
+
_tag: "ExpandMacro" as const,
|
|
286
|
+
version: 1,
|
|
287
|
+
...(title ? { title } : {}),
|
|
288
|
+
children: bodyBlocks
|
|
289
|
+
} satisfies ExpandMacro
|
|
290
|
+
)
|
|
291
|
+
i = j + 1
|
|
292
|
+
continue
|
|
293
|
+
}
|
|
294
|
+
// Synthetic empty-header marker: drop it and tell the next table to drop
|
|
295
|
+
// its first row (which the serializer added only to satisfy GFM).
|
|
296
|
+
if ((child as MdastHtml).value.trim() === "<!--cf:synth-thead-->") {
|
|
297
|
+
const next = children[i + 1]
|
|
298
|
+
if (next?.type === "table") {
|
|
299
|
+
const table = yield* parseTable(next as MdastTable, { dropSyntheticHeader: true })
|
|
300
|
+
nodes.push(table)
|
|
301
|
+
i += 2
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
i++
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
}
|
|
259
308
|
const node = yield* mdastNodeToBlock(child)
|
|
260
309
|
if (node !== null) nodes.push(node)
|
|
310
|
+
i++
|
|
261
311
|
}
|
|
262
312
|
return nodes
|
|
263
313
|
})
|
|
264
314
|
|
|
315
|
+
const decodeHtmlEntities = (s: string): string =>
|
|
316
|
+
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'")
|
|
317
|
+
|
|
265
318
|
/**
|
|
266
319
|
* Convert mdast node to BlockNode or MacroNode.
|
|
267
320
|
*/
|
|
@@ -580,6 +633,11 @@ const mdastNodeToInline = (node: MdastNode): Effect.Effect<InlineNode | null, Pa
|
|
|
580
633
|
|
|
581
634
|
case "link": {
|
|
582
635
|
const link = node as MdastLink
|
|
636
|
+
// Recognise the visible round-trip carriers emitted by MarkdownSerializer
|
|
637
|
+
// for inline macros (`#cf-user:`, `#cf-status:`, `#cf-toc:`) and rebuild
|
|
638
|
+
// the proper AST node so push back to Confluence is lossless.
|
|
639
|
+
const macroNode = parseInlineMacroLink(link)
|
|
640
|
+
if (macroNode) return macroNode
|
|
583
641
|
const children = yield* mdastChildrenToBaseInline(link.children)
|
|
584
642
|
return new Link({
|
|
585
643
|
href: link.url,
|
|
@@ -666,6 +724,41 @@ const parseInlineHtml = (html: string): Effect.Effect<InlineNode | null, ParseEr
|
|
|
666
724
|
return null
|
|
667
725
|
})
|
|
668
726
|
|
|
727
|
+
/**
|
|
728
|
+
* Recognise visible markdown links emitted as round-trip carriers for inline
|
|
729
|
+
* Confluence macros (UserMention, StatusMacro, TocMacro). Returns the rebuilt
|
|
730
|
+
* AST node, or null if the link is a regular link.
|
|
731
|
+
*
|
|
732
|
+
* The raw `<!--cf:status:title;color-->` form expects URL-encoded values;
|
|
733
|
+
* link text is the human-readable (decoded) form, so we re-encode it here.
|
|
734
|
+
* URL fragments are already URL-encoded, so they pass through verbatim.
|
|
735
|
+
*/
|
|
736
|
+
const parseInlineMacroLink = (link: MdastLink): InlineNode | null => {
|
|
737
|
+
const userMatch = link.url.match(/^#cf-user:(.+)$/)
|
|
738
|
+
if (userMatch) {
|
|
739
|
+
return new UserMention({ accountId: decodeURIComponent(userMatch[1] ?? "") })
|
|
740
|
+
}
|
|
741
|
+
const statusMatch = link.url.match(/^#cf-status:(.*)$/)
|
|
742
|
+
if (statusMatch) {
|
|
743
|
+
const text = link.children
|
|
744
|
+
.filter((c): c is MdastText => c.type === "text")
|
|
745
|
+
.map((c) => c.value)
|
|
746
|
+
.join("")
|
|
747
|
+
return new UnsupportedInline({
|
|
748
|
+
raw: `<!--cf:status:${encodeURIComponent(text)};${statusMatch[1] ?? ""}-->`,
|
|
749
|
+
source: "markdown"
|
|
750
|
+
})
|
|
751
|
+
}
|
|
752
|
+
const tocMatch = link.url.match(/^#cf-toc:([^:]*):([^:]*)$/)
|
|
753
|
+
if (tocMatch) {
|
|
754
|
+
return new UnsupportedInline({
|
|
755
|
+
raw: `<!--cf:toc:${tocMatch[1] ?? ""};${tocMatch[2] ?? ""}-->`,
|
|
756
|
+
source: "markdown"
|
|
757
|
+
})
|
|
758
|
+
}
|
|
759
|
+
return null
|
|
760
|
+
}
|
|
761
|
+
|
|
669
762
|
/**
|
|
670
763
|
* Parse comment-encoded task list.
|
|
671
764
|
* Format: <!--cf:tasklist:id|uuid|status|body;id|uuid|status|body-->
|
|
@@ -1065,16 +1158,16 @@ const mdastChildrenToBaseInline = (
|
|
|
1065
1158
|
})
|
|
1066
1159
|
|
|
1067
1160
|
/**
|
|
1068
|
-
* Convert mdast children to
|
|
1161
|
+
* Convert mdast children to block nodes for list items.
|
|
1162
|
+
*
|
|
1163
|
+
* Allows nested {@link NestedList} children — recurses on `list` mdast nodes so
|
|
1164
|
+
* second-level bullets become proper nested markdown lists rather than raw HTML.
|
|
1069
1165
|
*/
|
|
1070
|
-
const
|
|
1166
|
+
const mdastChildrenToListItemBlocks = (
|
|
1071
1167
|
children: Array<MdastNode>
|
|
1072
|
-
): Effect.Effect<
|
|
1073
|
-
Array<Heading | Paragraph | CodeBlock | ThematicBreak | Image | Table | UnsupportedBlock>,
|
|
1074
|
-
ParseError
|
|
1075
|
-
> =>
|
|
1168
|
+
): Effect.Effect<Array<SimpleBlock>, ParseError> =>
|
|
1076
1169
|
Effect.gen(function*() {
|
|
1077
|
-
const blocks: Array<
|
|
1170
|
+
const blocks: Array<SimpleBlock> = []
|
|
1078
1171
|
for (const child of children) {
|
|
1079
1172
|
switch (child.type) {
|
|
1080
1173
|
case "heading": {
|
|
@@ -1116,9 +1209,7 @@ const mdastChildrenToSimpleBlocks = (
|
|
|
1116
1209
|
break
|
|
1117
1210
|
}
|
|
1118
1211
|
case "list": {
|
|
1119
|
-
|
|
1120
|
-
// This should rarely happen as Confluence nested lists are preserved as HTML
|
|
1121
|
-
blocks.push(new UnsupportedBlock({ rawMarkdown: "", source: "markdown" }))
|
|
1212
|
+
blocks.push(yield* parseList(child as MdastList))
|
|
1122
1213
|
break
|
|
1123
1214
|
}
|
|
1124
1215
|
default: {
|
|
@@ -1129,8 +1220,44 @@ const mdastChildrenToSimpleBlocks = (
|
|
|
1129
1220
|
return blocks
|
|
1130
1221
|
})
|
|
1131
1222
|
|
|
1132
|
-
|
|
1133
|
-
|
|
1223
|
+
/**
|
|
1224
|
+
* Convert mdast children to simple block nodes (no nested lists).
|
|
1225
|
+
*
|
|
1226
|
+
* Used for AST nodes whose schema does not permit nested lists (BlockQuote etc.).
|
|
1227
|
+
*/
|
|
1228
|
+
const mdastChildrenToSimpleBlocks = (
|
|
1229
|
+
children: Array<MdastNode>
|
|
1230
|
+
): Effect.Effect<
|
|
1231
|
+
Array<Heading | Paragraph | CodeBlock | ThematicBreak | Image | Table | UnsupportedBlock>,
|
|
1232
|
+
ParseError
|
|
1233
|
+
> =>
|
|
1234
|
+
Effect.gen(function*() {
|
|
1235
|
+
const blocks = yield* mdastChildrenToListItemBlocks(children)
|
|
1236
|
+
return blocks.map((block) =>
|
|
1237
|
+
block._tag === "List"
|
|
1238
|
+
? new UnsupportedBlock({ rawMarkdown: "", source: "markdown" })
|
|
1239
|
+
: block
|
|
1240
|
+
)
|
|
1241
|
+
})
|
|
1242
|
+
|
|
1243
|
+
// Type for simple blocks used in lists. Recursive: a list item may contain nested Lists.
|
|
1244
|
+
type SimpleBlock =
|
|
1245
|
+
| Heading
|
|
1246
|
+
| Paragraph
|
|
1247
|
+
| CodeBlock
|
|
1248
|
+
| ThematicBreak
|
|
1249
|
+
| Image
|
|
1250
|
+
| Table
|
|
1251
|
+
| UnsupportedBlock
|
|
1252
|
+
| NestedList
|
|
1253
|
+
|
|
1254
|
+
type NestedList = {
|
|
1255
|
+
_tag: "List"
|
|
1256
|
+
version: number
|
|
1257
|
+
ordered: boolean
|
|
1258
|
+
start?: number
|
|
1259
|
+
children: Array<{ _tag: "ListItem"; checked?: boolean; children: Array<SimpleBlock> }>
|
|
1260
|
+
}
|
|
1134
1261
|
|
|
1135
1262
|
/**
|
|
1136
1263
|
* Parse mdast list.
|
|
@@ -1153,7 +1280,7 @@ const parseList = (
|
|
|
1153
1280
|
const start = ordered && list.start != null ? list.start : undefined
|
|
1154
1281
|
|
|
1155
1282
|
for (const item of list.children) {
|
|
1156
|
-
const children = yield*
|
|
1283
|
+
const children = yield* mdastChildrenToListItemBlocks(item.children)
|
|
1157
1284
|
if (item.checked != null) {
|
|
1158
1285
|
items.push({ _tag: "ListItem", checked: item.checked, children })
|
|
1159
1286
|
} else {
|
|
@@ -1169,8 +1296,17 @@ const parseList = (
|
|
|
1169
1296
|
|
|
1170
1297
|
/**
|
|
1171
1298
|
* Parse mdast table.
|
|
1299
|
+
*
|
|
1300
|
+
* `dropSyntheticHeader` is set by `mdastToDocumentNodes` when the table was
|
|
1301
|
+
* preceded by a `<!--cf:synth-thead-->` marker — only then do we discard the
|
|
1302
|
+
* first row (which the serializer added solely to satisfy GFM's required
|
|
1303
|
+
* divider line). A legitimate empty `<thead>` from real Confluence storage is
|
|
1304
|
+
* always preserved.
|
|
1172
1305
|
*/
|
|
1173
|
-
const parseTable = (
|
|
1306
|
+
const parseTable = (
|
|
1307
|
+
table: MdastTable,
|
|
1308
|
+
options: { dropSyntheticHeader?: boolean } = {}
|
|
1309
|
+
): Effect.Effect<Table, ParseError> =>
|
|
1174
1310
|
Effect.gen(function*() {
|
|
1175
1311
|
let header: TableRow | undefined
|
|
1176
1312
|
const rows: Array<TableRow> = []
|
|
@@ -1194,5 +1330,9 @@ const parseTable = (table: MdastTable): Effect.Effect<Table, ParseError> =>
|
|
|
1194
1330
|
}
|
|
1195
1331
|
}
|
|
1196
1332
|
|
|
1333
|
+
if (options.dropSyntheticHeader) {
|
|
1334
|
+
header = undefined
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1197
1337
|
return new Table({ header, rows })
|
|
1198
1338
|
})
|
|
@@ -290,8 +290,17 @@ const processSingleMacro = (macroContent: string): string => {
|
|
|
290
290
|
return `<span data-macro="status" data-color="${colorMatch?.[1] ?? ""}">${escapeHtml(titleMatch?.[1] ?? "")}</span>`
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
-
//
|
|
294
|
-
|
|
293
|
+
// view-file macro: render as a visible anchor pointing at the attachment.
|
|
294
|
+
if (macroName === "view-file") {
|
|
295
|
+
const filenameMatch = macroContent.match(/ri:filename="([^"]+)"/)
|
|
296
|
+
const filename = filenameMatch?.[1] ?? ""
|
|
297
|
+
return `<a data-macro="view-file" href="attachment:${escapeHtml(filename)}">${escapeHtml(filename)}</a>`
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Unknown macro - preserve as unsupported. The body is HTML-escaped so the
|
|
301
|
+
// next iteration of processStructuredMacros doesn't re-match the same
|
|
302
|
+
// <ac:structured-macro> tag and loop forever.
|
|
303
|
+
return `<div data-unsupported-macro="${macroName}">${escapeHtml(macroContent)}</div>`
|
|
295
304
|
}
|
|
296
305
|
|
|
297
306
|
/**
|
|
@@ -359,7 +359,7 @@ const serializeTable = (
|
|
|
359
359
|
return parts.join("")
|
|
360
360
|
})
|
|
361
361
|
|
|
362
|
-
// Simple block type for list items
|
|
362
|
+
// Simple block type for list items (allows nested Lists for sub-bullets).
|
|
363
363
|
type SimpleBlock =
|
|
364
364
|
| Heading
|
|
365
365
|
| Paragraph
|
|
@@ -368,6 +368,15 @@ type SimpleBlock =
|
|
|
368
368
|
| Image
|
|
369
369
|
| Table
|
|
370
370
|
| UnsupportedBlock
|
|
371
|
+
| NestedList
|
|
372
|
+
|
|
373
|
+
// Structural shape of a nested list inside a list item.
|
|
374
|
+
type NestedList = {
|
|
375
|
+
readonly _tag: "List"
|
|
376
|
+
readonly ordered: boolean
|
|
377
|
+
readonly start?: number | undefined
|
|
378
|
+
readonly children: ReadonlyArray<ListItemType>
|
|
379
|
+
}
|
|
371
380
|
|
|
372
381
|
// List item type
|
|
373
382
|
type ListItemType = {
|
|
@@ -436,6 +445,8 @@ const serializeSimpleBlock = (node: SimpleBlock): Effect.Effect<string, Serializ
|
|
|
436
445
|
const unsupported = node as unknown as { rawHtml?: string; rawMarkdown?: string }
|
|
437
446
|
return unsupported.rawHtml || unsupported.rawMarkdown || ""
|
|
438
447
|
}
|
|
448
|
+
case "List":
|
|
449
|
+
return yield* serializeList(node)
|
|
439
450
|
default:
|
|
440
451
|
return ""
|
|
441
452
|
}
|
|
@@ -646,6 +657,15 @@ const serializeInlineNode = (node: InlineNode): Effect.Effect<string, SerializeE
|
|
|
646
657
|
case "InlineCode":
|
|
647
658
|
return `<code>${escapeHtml(node.value)}</code>`
|
|
648
659
|
case "Link": {
|
|
660
|
+
// Round-trip view-file macro: a Link with href="attachment:FILENAME"
|
|
661
|
+
// came from a Confluence ac:structured-macro name="view-file".
|
|
662
|
+
const attachmentMatch = node.href.match(/^attachment:(.+)$/)
|
|
663
|
+
if (attachmentMatch) {
|
|
664
|
+
const filename = attachmentMatch[1] ?? ""
|
|
665
|
+
return `<ac:structured-macro ac:name="view-file"><ac:parameter ac:name="name"><ri:attachment ri:filename="${
|
|
666
|
+
escapeHtml(filename)
|
|
667
|
+
}"/></ac:parameter></ac:structured-macro>`
|
|
668
|
+
}
|
|
649
669
|
const content = yield* serializeInlineNodes(node.children)
|
|
650
670
|
const title = node.title ? ` title="${escapeHtml(node.title)}"` : ""
|
|
651
671
|
return `<a href="${escapeHtml(node.href)}"${title}>${content}</a>`
|
|
@@ -195,7 +195,15 @@ const serializeTable = (
|
|
|
195
195
|
Effect.gen(function*() {
|
|
196
196
|
const lines: Array<string> = []
|
|
197
197
|
|
|
198
|
-
//
|
|
198
|
+
// Markdown tables require a header divider. When the source had no <thead>,
|
|
199
|
+
// emit a synthetic empty header so the table still renders. A leading
|
|
200
|
+
// <!--cf:synth-thead--> marker discriminates it from a legitimate empty
|
|
201
|
+
// header — MarkdownParser strips the marker and drops only that synthetic row,
|
|
202
|
+
// so a real Confluence <thead> with empty <th>s round-trips intact.
|
|
203
|
+
const columnCount = node.header?.cells.length ?? node.rows[0]?.cells.length ?? 0
|
|
204
|
+
const synthMarker = "<!--cf:synth-thead-->"
|
|
205
|
+
let prefix = ""
|
|
206
|
+
|
|
199
207
|
if (node.header) {
|
|
200
208
|
const headerCells: Array<string> = []
|
|
201
209
|
for (const cell of node.header.cells) {
|
|
@@ -203,6 +211,10 @@ const serializeTable = (
|
|
|
203
211
|
}
|
|
204
212
|
lines.push(`| ${headerCells.join(" | ")} |`)
|
|
205
213
|
lines.push(`| ${headerCells.map(() => "---").join(" | ")} |`)
|
|
214
|
+
} else if (columnCount > 0) {
|
|
215
|
+
prefix = `${synthMarker}\n\n`
|
|
216
|
+
lines.push(`| ${Array(columnCount).fill("").join(" | ")} |`)
|
|
217
|
+
lines.push(`| ${Array(columnCount).fill("---").join(" | ")} |`)
|
|
206
218
|
}
|
|
207
219
|
|
|
208
220
|
// Body rows
|
|
@@ -214,10 +226,10 @@ const serializeTable = (
|
|
|
214
226
|
lines.push(`| ${cells.join(" | ")} |`)
|
|
215
227
|
}
|
|
216
228
|
|
|
217
|
-
return lines.join("\n")
|
|
229
|
+
return `${prefix}${lines.join("\n")}`
|
|
218
230
|
})
|
|
219
231
|
|
|
220
|
-
// Simple block type for list items
|
|
232
|
+
// Simple block type for list items (allows nested Lists for sub-bullets).
|
|
221
233
|
type SimpleBlock =
|
|
222
234
|
| Heading
|
|
223
235
|
| Paragraph
|
|
@@ -226,6 +238,15 @@ type SimpleBlock =
|
|
|
226
238
|
| Image
|
|
227
239
|
| Table
|
|
228
240
|
| UnsupportedBlock
|
|
241
|
+
| NestedList
|
|
242
|
+
|
|
243
|
+
// Structural shape of a nested list inside a list item.
|
|
244
|
+
type NestedList = {
|
|
245
|
+
readonly _tag: "List"
|
|
246
|
+
readonly ordered: boolean
|
|
247
|
+
readonly start?: number | undefined
|
|
248
|
+
readonly children: ReadonlyArray<ListItemType>
|
|
249
|
+
}
|
|
229
250
|
|
|
230
251
|
// List item type
|
|
231
252
|
type ListItemType = {
|
|
@@ -297,6 +318,8 @@ const serializeSimpleBlock = (node: SimpleBlock): Effect.Effect<string, Serializ
|
|
|
297
318
|
const unsupported = node as unknown as { rawMarkdown?: string; rawHtml?: string }
|
|
298
319
|
return unsupported.rawMarkdown || unsupported.rawHtml || ""
|
|
299
320
|
}
|
|
321
|
+
case "List":
|
|
322
|
+
return yield* serializeList(node)
|
|
300
323
|
default:
|
|
301
324
|
return ""
|
|
302
325
|
}
|
|
@@ -339,7 +362,11 @@ const serializeInfoPanel = (
|
|
|
339
362
|
})
|
|
340
363
|
|
|
341
364
|
/**
|
|
342
|
-
* Serialize expand macro
|
|
365
|
+
* Serialize expand macro as a GFM-compatible <details> block.
|
|
366
|
+
*
|
|
367
|
+
* The opening and closing HTML tags are recognised on round-trip by
|
|
368
|
+
* MarkdownParser to rebuild the ExpandMacro AST node. Body content is rendered
|
|
369
|
+
* as ordinary markdown so it shows up in viewers (Obsidian, GitHub, etc.).
|
|
343
370
|
*/
|
|
344
371
|
const serializeExpandMacro = (
|
|
345
372
|
node: { title?: string | undefined; children: ReadonlyArray<SimpleBlock> }
|
|
@@ -353,11 +380,13 @@ const serializeExpandMacro = (
|
|
|
353
380
|
contentParts.push(serialized)
|
|
354
381
|
}
|
|
355
382
|
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
return
|
|
383
|
+
const body = contentParts.join("\n\n")
|
|
384
|
+
const summary = `<summary>${escapeHtml(title)}</summary>`
|
|
385
|
+
return `<details>\n${summary}\n\n${body}\n\n</details>`
|
|
359
386
|
})
|
|
360
387
|
|
|
388
|
+
const escapeHtml = (s: string): string => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
389
|
+
|
|
361
390
|
/**
|
|
362
391
|
* Serialize TOC macro.
|
|
363
392
|
*/
|
|
@@ -470,8 +499,10 @@ const serializeInlineNode = (node: InlineNode): Effect.Effect<string, SerializeE
|
|
|
470
499
|
encodeURIComponent(node.fallback)
|
|
471
500
|
}-->`
|
|
472
501
|
case "UserMention":
|
|
473
|
-
//
|
|
474
|
-
|
|
502
|
+
// Render as a markdown link so the mention is visible in viewers.
|
|
503
|
+
// The #cf-user: URL fragment is the round-trip carrier — MarkdownParser
|
|
504
|
+
// rebuilds the UserMention node when it sees a link with that prefix.
|
|
505
|
+
return `[@${node.accountId}](#cf-user:${encodeURIComponent(node.accountId)})`
|
|
475
506
|
case "DateTime":
|
|
476
507
|
// Wrap in HTML comment to prevent remark from parsing
|
|
477
508
|
return `<!--cf:date:${node.datetime}-->`
|
|
@@ -485,8 +516,27 @@ const serializeInlineNode = (node: InlineNode): Effect.Effect<string, SerializeE
|
|
|
485
516
|
const content = yield* serializeInlineNodes(node.children)
|
|
486
517
|
return `<span style="background-color: ${node.backgroundColor};">${content}</span>`
|
|
487
518
|
}
|
|
488
|
-
case "UnsupportedInline":
|
|
519
|
+
case "UnsupportedInline": {
|
|
520
|
+
// Some inline macros are stored as UnsupportedInline carrying a comment-
|
|
521
|
+
// encoded round-trip marker. Rewrite the round-trippable ones as visible
|
|
522
|
+
// markdown links so they show up in viewers; MarkdownParser reverses this.
|
|
523
|
+
// The raw payload is already URL-encoded (ConfluenceParser uses
|
|
524
|
+
// encodeURIComponent), so we decode the title for human-readable link
|
|
525
|
+
// text and pass the encoded value through to the URL fragment as-is.
|
|
526
|
+
const statusMatch = node.raw.match(/^<!--cf:status:([^;]*);([^;]*)-->$/)
|
|
527
|
+
if (statusMatch) {
|
|
528
|
+
const text = decodeURIComponent(statusMatch[1] ?? "")
|
|
529
|
+
const color = statusMatch[2] ?? ""
|
|
530
|
+
return `[${text}](#cf-status:${color})`
|
|
531
|
+
}
|
|
532
|
+
const tocMatch = node.raw.match(/^<!--cf:toc:([^;]*);([^;]*)-->$/)
|
|
533
|
+
if (tocMatch) {
|
|
534
|
+
const min = tocMatch[1] ?? ""
|
|
535
|
+
const max = tocMatch[2] ?? ""
|
|
536
|
+
return `[Table of Contents](#cf-toc:${min}:${max})`
|
|
537
|
+
}
|
|
489
538
|
return node.raw
|
|
539
|
+
}
|
|
490
540
|
default:
|
|
491
541
|
return ""
|
|
492
542
|
}
|