@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.
@@ -216,6 +216,58 @@ describe("ConfluenceParser", () => {
216
216
  }
217
217
  }
218
218
  }))
219
+
220
+ it.effect("parses nested unordered list as nested List AST", () =>
221
+ Effect.gen(function*() {
222
+ const html = `<ul><li><p>Outer</p><ul><li><p>Inner A</p></li><li><p>Inner B</p></li></ul></li></ul>`
223
+ const doc = yield* parseConfluenceHtml(html)
224
+ const outerList = doc.children[0]
225
+ expect(outerList?._tag).toBe("List")
226
+ if (outerList?._tag === "List") {
227
+ expect(outerList.children.length).toBe(1)
228
+ const outerItem = outerList.children[0]
229
+ expect(outerItem?.children.length).toBe(2)
230
+ // First child is the "Outer" paragraph
231
+ expect(outerItem?.children[0]?._tag).toBe("Paragraph")
232
+ // Second child is the nested List (no longer UnsupportedBlock)
233
+ const nested = outerItem?.children[1]
234
+ expect(nested?._tag).toBe("List")
235
+ if (nested?._tag === "List") {
236
+ expect(nested.children.length).toBe(2)
237
+ const firstInner = nested.children[0]
238
+ expect(firstInner?.children[0]?._tag).toBe("Paragraph")
239
+ }
240
+ }
241
+ }))
242
+
243
+ it.effect("serializes nested list as indented markdown bullets", () =>
244
+ Effect.gen(function*() {
245
+ const html = `<ul><li><p>Outer</p><ul><li><p>Inner A</p></li><li><p>Inner B</p></li></ul></li></ul>`
246
+ const doc = yield* parseConfluenceHtml(html)
247
+ const md = yield* serializeToMarkdown(doc, { includeRawSource: false })
248
+ expect(md).toContain("- Outer")
249
+ expect(md).toContain(" - Inner A")
250
+ expect(md).toContain(" - Inner B")
251
+ expect(md).not.toContain("<ul")
252
+ expect(md).not.toContain("local-id")
253
+ }))
254
+
255
+ it.effect("unwraps table cell with leading empty <p> placeholder", () =>
256
+ Effect.gen(function*() {
257
+ // Confluence editor frequently emits an empty <p/> alongside the real content.
258
+ const html = `<table><tbody>
259
+ <tr><td><p local-id="empty"/><p><strong>Must</strong></p></td><td><p>Text</p></td></tr>
260
+ </tbody></table>`
261
+ const doc = yield* parseConfluenceHtml(html)
262
+ const table = doc.children[0]
263
+ expect(table?._tag).toBe("Table")
264
+ if (table?._tag === "Table") {
265
+ const cell = table.rows[0]?.cells[0]
266
+ expect(cell?.children.length).toBe(1)
267
+ const strong = cell?.children[0]
268
+ expect(strong?._tag).toBe("Strong")
269
+ }
270
+ }))
219
271
  })
220
272
 
221
273
  describe("integration test fixture", () => {
@@ -263,6 +315,123 @@ describe("ConfluenceParser", () => {
263
315
  expect(finalHtml).toBe(originalHtml)
264
316
  }))
265
317
 
318
+ it.effect("renders UserMention as visible markdown link and roundtrips", () =>
319
+ Effect.gen(function*() {
320
+ const originalHtml = `<p><ac:link><ri:user ri:account-id="557058:abc123"/></ac:link></p>`
321
+ const doc1 = yield* parseConfluenceHtml(originalHtml)
322
+ const md = yield* serializeToMarkdown(doc1, { includeRawSource: false })
323
+ expect(md).toContain("[@557058:abc123](#cf-user:")
324
+ expect(md).not.toContain("<!--cf:user:")
325
+ const doc2 = yield* parseMarkdown(md)
326
+ const finalHtml = yield* serializeToConfluence(doc2)
327
+ expect(finalHtml).toContain("ri:account-id=\"557058:abc123\"")
328
+ }))
329
+
330
+ it.effect("renders inline StatusMacro as visible link and roundtrips", () =>
331
+ Effect.gen(function*() {
332
+ const originalHtml =
333
+ `<p><ac:structured-macro ac:name="status"><ac:parameter ac:name="title">READY</ac:parameter><ac:parameter ac:name="colour">Blue</ac:parameter></ac:structured-macro></p>`
334
+ const doc1 = yield* parseConfluenceHtml(originalHtml)
335
+ const md = yield* serializeToMarkdown(doc1, { includeRawSource: false })
336
+ expect(md).toContain("[READY](#cf-status:Blue)")
337
+ expect(md).not.toContain("<!--cf:status:")
338
+ const doc2 = yield* parseMarkdown(md)
339
+ const finalHtml = yield* serializeToConfluence(doc2)
340
+ expect(finalHtml).toContain("ac:name=\"status\"")
341
+ expect(finalHtml).toContain("READY")
342
+ expect(finalHtml).toContain("Blue")
343
+ }))
344
+
345
+ it.effect("decodes multi-word status title for visible link text", () =>
346
+ Effect.gen(function*() {
347
+ const originalHtml =
348
+ `<p><ac:structured-macro ac:name="status"><ac:parameter ac:name="title">In Progress</ac:parameter><ac:parameter ac:name="colour">Yellow</ac:parameter></ac:structured-macro></p>`
349
+ const doc1 = yield* parseConfluenceHtml(originalHtml)
350
+ const md = yield* serializeToMarkdown(doc1, { includeRawSource: false })
351
+ // Display text is decoded; URL fragment carries the encoded value.
352
+ expect(md).toContain("[In Progress](#cf-status:Yellow)")
353
+ expect(md).not.toContain("In%20Progress")
354
+ const finalHtml = yield* serializeToConfluence(yield* parseMarkdown(md))
355
+ expect(finalHtml).toContain("In Progress")
356
+ }))
357
+
358
+ it.effect("preserves real <thead> with empty header cells (no synthetic-header drop)", () =>
359
+ Effect.gen(function*() {
360
+ // A legitimate empty <thead> in the source must round-trip intact —
361
+ // it must NOT be confused with the synthetic empty header the
362
+ // serializer emits for <tbody>-only tables.
363
+ const originalHtml =
364
+ `<table><thead><tr><th></th><th></th></tr></thead><tbody><tr><td>a</td><td>b</td></tr></tbody></table>`
365
+ const doc1 = yield* parseConfluenceHtml(originalHtml)
366
+ const md = yield* serializeToMarkdown(doc1, { includeRawSource: false })
367
+ expect(md).not.toContain("cf:synth-thead")
368
+ const doc2 = yield* parseMarkdown(md)
369
+ const finalHtml = yield* serializeToConfluence(doc2)
370
+ expect(finalHtml).toBe(originalHtml)
371
+ }))
372
+
373
+ it.effect("renders view-file macro as a link to the attachment", () =>
374
+ Effect.gen(function*() {
375
+ const originalHtml =
376
+ `<p><ac:structured-macro ac:name="view-file"><ac:parameter ac:name="name"><ri:attachment ri:filename="guidelines.md"/></ac:parameter></ac:structured-macro></p>`
377
+ const doc1 = yield* parseConfluenceHtml(originalHtml)
378
+ const md = yield* serializeToMarkdown(doc1, { includeRawSource: false })
379
+ expect(md).toContain("[guidelines.md](attachment:guidelines.md)")
380
+ const doc2 = yield* parseMarkdown(md)
381
+ const finalHtml = yield* serializeToConfluence(doc2)
382
+ expect(finalHtml).toContain("ac:name=\"view-file\"")
383
+ expect(finalHtml).toContain("ri:filename=\"guidelines.md\"")
384
+ }))
385
+
386
+ it.effect("roundtrips nested unordered list HTML -> markdown -> HTML", () =>
387
+ Effect.gen(function*() {
388
+ const originalHtml = `<ul><li><p>Outer</p><ul><li><p>Inner A</p></li><li><p>Inner B</p></li></ul></li></ul>`
389
+ const doc1 = yield* parseConfluenceHtml(originalHtml)
390
+ const md = yield* serializeToMarkdown(doc1, { includeRawSource: false })
391
+ const doc2 = yield* parseMarkdown(md)
392
+ const finalHtml = yield* serializeToConfluence(doc2)
393
+ expect(finalHtml).toBe(originalHtml)
394
+ }))
395
+
396
+ it.effect("emits markdown header divider for headerless table and roundtrips", () =>
397
+ Effect.gen(function*() {
398
+ // Tables without <thead> need a synthetic header in markdown to render.
399
+ const originalHtml =
400
+ `<table><tbody><tr><td>Cell A1</td><td>Cell A2</td></tr><tr><td>Cell B1</td><td>Cell B2</td></tr></tbody></table>`
401
+ const doc1 = yield* parseConfluenceHtml(originalHtml)
402
+ const md = yield* serializeToMarkdown(doc1, { includeRawSource: false })
403
+ // Synthetic empty header + divider so md viewers render it as a table.
404
+ expect(md).toMatch(/\|\s*\|\s*\|\n\| --- \| --- \|/)
405
+ expect(md).toContain("| Cell A1 | Cell A2 |")
406
+ expect(md).toContain("| Cell B1 | Cell B2 |")
407
+ // Roundtrip: the synthetic empty header is dropped on parse, so the
408
+ // Confluence output omits <thead> just like the source.
409
+ const doc2 = yield* parseMarkdown(md)
410
+ const finalHtml = yield* serializeToConfluence(doc2)
411
+ expect(finalHtml).toBe(originalHtml)
412
+ }))
413
+
414
+ it.effect("renders ExpandMacro as <details>/<summary> and roundtrips", () =>
415
+ Effect.gen(function*() {
416
+ const originalHtml =
417
+ `<ac:structured-macro ac:name="expand"><ac:parameter ac:name="title">Glossary term</ac:parameter><ac:rich-text-body><p>Body content here.</p></ac:rich-text-body></ac:structured-macro>`
418
+ const doc1 = yield* parseConfluenceHtml(originalHtml)
419
+ const md = yield* serializeToMarkdown(doc1, { includeRawSource: false })
420
+ expect(md).toContain("<details>")
421
+ expect(md).toContain("<summary>Glossary term</summary>")
422
+ expect(md).toContain("Body content here.")
423
+ expect(md).toContain("</details>")
424
+ // No more opaque cf:expand comment.
425
+ expect(md).not.toContain("<!--cf:expand:")
426
+
427
+ const doc2 = yield* parseMarkdown(md)
428
+ expect(doc2.children[0]?._tag).toBe("ExpandMacro")
429
+ const finalHtml = yield* serializeToConfluence(doc2)
430
+ expect(finalHtml).toContain("ac:name=\"expand\"")
431
+ expect(finalHtml).toContain("Glossary term")
432
+ expect(finalHtml).toContain("Body content here.")
433
+ }))
434
+
266
435
  it.effect("roundtrips TOC macro", () =>
267
436
  Effect.gen(function*() {
268
437
  const html =