@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.
@@ -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
- * @category BlockNode
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 Types
310
+ * @category BlockNode
321
311
  */
322
- export type ListItem = Schema.Schema.Type<typeof ListItem>
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 const List = Schema.Struct({
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
- // Find actual element children (skip whitespace text)
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 true
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 used in lists
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(new UnsupportedBlock({ rawHtml: hastToHtml(el), source: "confluence" }))
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
- } // Nested lists - convert to paragraph with raw HTML for now (will be handled later)
885
- else if (tagName === "ul" || tagName === "ol") {
886
- // For nested lists, preserve as unsupported for now
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
- for (const child of root.children) {
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(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&#39;/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 simple block nodes.
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 mdastChildrenToSimpleBlocks = (
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<Heading | Paragraph | CodeBlock | ThematicBreak | Image | Table | UnsupportedBlock> = []
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
- // Nested lists - when markdown nested lists are parsed, we lose Confluence local-ids
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
- // Type for simple blocks used in lists
1133
- type SimpleBlock = Heading | Paragraph | CodeBlock | ThematicBreak | Image | Table | UnsupportedBlock
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* mdastChildrenToSimpleBlocks(item.children)
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 = (table: MdastTable): Effect.Effect<Table, ParseError> =>
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
- // Unknown macro - preserve as unsupported
294
- return `<div data-unsupported-macro="${macroName}">${macroContent}</div>`
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
- // Header
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 - use comment encoding for roundtrip.
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 content = contentParts.join("\n")
357
- // Use comment encoding for roundtrip
358
- return `<!--cf:expand:${encodeURIComponent(title)}:${encodeURIComponent(content)}-->`
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
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
- // Wrap in HTML comment to prevent remark from parsing
474
- return `<!--cf:user:${node.accountId}-->`
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
  }