confpub-cli 1.4.4__tar.gz → 1.5.0__tar.gz

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 (50) hide show
  1. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/PKG-INFO +11 -2
  2. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/README.md +10 -1
  3. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/__init__.py +1 -1
  4. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/converter.py +232 -6
  5. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/guide.py +32 -0
  6. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/reverse_converter.py +163 -4
  7. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_converter.py +171 -0
  8. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_reverse_converter.py +152 -0
  9. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/uv.lock +27 -1
  10. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/.github/workflows/publish.yml +0 -0
  11. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/.gitignore +0 -0
  12. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/CLAUDE.md +0 -0
  13. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/LICENSE +0 -0
  14. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/PRD.md +0 -0
  15. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/applier.py +0 -0
  16. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/assets.py +0 -0
  17. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/cli.py +0 -0
  18. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/config.py +0 -0
  19. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/confluence.py +0 -0
  20. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/envelope.py +0 -0
  21. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/errors.py +0 -0
  22. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/lockfile.py +0 -0
  23. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/manifest.py +0 -0
  24. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/output.py +0 -0
  25. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/planner.py +0 -0
  26. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/publish.py +0 -0
  27. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/puller.py +0 -0
  28. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/py.typed +0 -0
  29. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/validator.py +0 -0
  30. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/verifier.py +0 -0
  31. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub.lock +0 -0
  32. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/pyproject.toml +0 -0
  33. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/__init__.py +0 -0
  34. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/conftest.py +0 -0
  35. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_applier.py +0 -0
  36. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_assets.py +0 -0
  37. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_config.py +0 -0
  38. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_confluence.py +0 -0
  39. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_envelope.py +0 -0
  40. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_errors.py +0 -0
  41. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_guide.py +0 -0
  42. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_integration.py +0 -0
  43. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_lockfile.py +0 -0
  44. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_manifest.py +0 -0
  45. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_output.py +0 -0
  46. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_planner.py +0 -0
  47. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_publish.py +0 -0
  48. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_puller.py +0 -0
  49. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_validator.py +0 -0
  50. {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_verifier.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: confpub-cli
3
- Version: 1.4.4
3
+ Version: 1.5.0
4
4
  Summary: Agent-first CLI to publish Markdown to Confluence
5
5
  Project-URL: Homepage, https://github.com/ThomasRohde/confpub-cli
6
6
  Project-URL: Repository, https://github.com/ThomasRohde/confpub-cli.git
@@ -118,7 +118,7 @@ confpub plan apply --plan confpub-plan.json --dry-run
118
118
 
119
119
  - **Structured JSON output** — every command returns the same envelope shape on stdout
120
120
  - **Transactional workflow** — plan → validate → apply → verify with fingerprint-based conflict detection
121
- - **Markdown → Confluence** — code blocks become code macros, `> [!NOTE]` becomes Info panels, tables stay tables
121
+ - **Markdown → Confluence** — code blocks become code macros, `> [!NOTE]` becomes Info panels, tables stay tables, task lists, math, definition lists, footnotes, panels, expand/collapse, and page layouts
122
122
  - **Asset handling** — images are uploaded as attachments and URLs are rewritten automatically
123
123
  - **Idempotent** — a lockfile tracks page IDs so re-publishing updates in place
124
124
  - **Agent-ready** — `confpub guide` returns the full CLI schema; `LLM=true` suppresses interactive behavior
@@ -344,6 +344,15 @@ confpub converts Markdown to Confluence Storage Format (and back via `page pull`
344
344
  | `![img](photo.png)` | Upload attachment + `<ac:image>` reference |
345
345
  | Tables | Standard XHTML `<table>` |
346
346
  | `~~strikethrough~~` | `<del>strikethrough</del>` |
347
+ | `- [ ] task` / `- [x] done` | `<ac:task-list>` with task status |
348
+ | `$E=mc^2$` | LaTeX math macro (inline) |
349
+ | `$$...$$` | LaTeX math macro (block) |
350
+ | `Term` + `: Definition` | `<dl><dt><dd>` definition list |
351
+ | `[^1]` footnotes | Superscript links + numbered list |
352
+ | `::: panel Title` | Confluence Panel macro |
353
+ | `::: expand Title` | Confluence Expand macro |
354
+ | `:::: layout two-equal` | Confluence page layout |
355
+ | `---yaml---` front matter | Silently stripped |
347
356
 
348
357
  ---
349
358
 
@@ -77,7 +77,7 @@ confpub plan apply --plan confpub-plan.json --dry-run
77
77
 
78
78
  - **Structured JSON output** — every command returns the same envelope shape on stdout
79
79
  - **Transactional workflow** — plan → validate → apply → verify with fingerprint-based conflict detection
80
- - **Markdown → Confluence** — code blocks become code macros, `> [!NOTE]` becomes Info panels, tables stay tables
80
+ - **Markdown → Confluence** — code blocks become code macros, `> [!NOTE]` becomes Info panels, tables stay tables, task lists, math, definition lists, footnotes, panels, expand/collapse, and page layouts
81
81
  - **Asset handling** — images are uploaded as attachments and URLs are rewritten automatically
82
82
  - **Idempotent** — a lockfile tracks page IDs so re-publishing updates in place
83
83
  - **Agent-ready** — `confpub guide` returns the full CLI schema; `LLM=true` suppresses interactive behavior
@@ -303,6 +303,15 @@ confpub converts Markdown to Confluence Storage Format (and back via `page pull`
303
303
  | `![img](photo.png)` | Upload attachment + `<ac:image>` reference |
304
304
  | Tables | Standard XHTML `<table>` |
305
305
  | `~~strikethrough~~` | `<del>strikethrough</del>` |
306
+ | `- [ ] task` / `- [x] done` | `<ac:task-list>` with task status |
307
+ | `$E=mc^2$` | LaTeX math macro (inline) |
308
+ | `$$...$$` | LaTeX math macro (block) |
309
+ | `Term` + `: Definition` | `<dl><dt><dd>` definition list |
310
+ | `[^1]` footnotes | Superscript links + numbered list |
311
+ | `::: panel Title` | Confluence Panel macro |
312
+ | `::: expand Title` | Confluence Expand macro |
313
+ | `:::: layout two-equal` | Confluence page layout |
314
+ | `---yaml---` front matter | Silently stripped |
306
315
 
307
316
  ---
308
317
 
@@ -1,3 +1,3 @@
1
1
  """confpub — Agent-first CLI to publish Markdown to Confluence."""
2
2
 
3
- __version__ = "1.4.4"
3
+ __version__ = "1.5.0"
@@ -15,6 +15,12 @@ from typing import Any
15
15
 
16
16
  from markdown_it import MarkdownIt
17
17
  from markdown_it.token import Token
18
+ from mdit_py_plugins.tasklists import tasklists_plugin
19
+ from mdit_py_plugins.dollarmath import dollarmath_plugin
20
+ from mdit_py_plugins.deflist import deflist_plugin
21
+ from mdit_py_plugins.footnote import footnote_plugin
22
+ from mdit_py_plugins.front_matter import front_matter_plugin
23
+ from mdit_py_plugins.container import container_plugin
18
24
 
19
25
  # Admonition types mapping: GitHub [!TYPE] → Confluence macro name
20
26
  ADMONITION_MAP: dict[str, str] = {
@@ -34,12 +40,16 @@ class ConfluenceRenderer:
34
40
 
35
41
  def __init__(self) -> None:
36
42
  self._output: list[str] = []
37
- self._list_stack: list[str] = [] # track nested ol/ul
43
+ self._list_stack: list[str] = [] # track nested ol/ul ("ol", "ul", "task-list")
44
+ self._task_id: int = 0 # incrementing counter for Confluence task IDs
45
+ self._footnote_refs: dict[int, int] = {} # meta.id → display number
38
46
 
39
47
  def render(self, tokens: list[Token], options: dict[str, Any], env: dict[str, Any]) -> str:
40
48
  """Render a list of tokens to Confluence Storage Format."""
41
49
  self._output = []
42
50
  self._list_stack = []
51
+ self._task_id = 0
52
+ self._footnote_refs = {}
43
53
  i = 0
44
54
  while i < len(tokens):
45
55
  token = tokens[i]
@@ -71,10 +81,14 @@ class ConfluenceRenderer:
71
81
  return idx + 1
72
82
 
73
83
  def _render_paragraph_open(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
84
+ if self._list_stack and self._list_stack[-1] == "task-list":
85
+ return idx + 1 # suppress <p> inside task-body
74
86
  self._output.append("<p>")
75
87
  return idx + 1
76
88
 
77
89
  def _render_paragraph_close(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
90
+ if self._list_stack and self._list_stack[-1] == "task-list":
91
+ return idx + 1 # suppress </p> inside task-body
78
92
  self._output.append("</p>")
79
93
  return idx + 1
80
94
 
@@ -94,6 +108,10 @@ class ConfluenceRenderer:
94
108
  # Fallback for unknown inline types
95
109
  if token.type == "text":
96
110
  self._output.append(escape(token.content))
111
+ elif token.type == "math_inline":
112
+ self._inline_math_inline(token)
113
+ elif token.type == "footnote_ref":
114
+ self._inline_footnote_ref(token)
97
115
 
98
116
  # ------------------------------------------------------------------
99
117
  # Inline tokens
@@ -253,24 +271,60 @@ class ConfluenceRenderer:
253
271
  return idx + 1
254
272
 
255
273
  def _render_bullet_list_open(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
256
- self._output.append("<ul>")
257
- self._list_stack.append("ul")
274
+ token = tokens[idx]
275
+ attrs = token.attrs or {}
276
+ if isinstance(attrs, dict) and attrs.get("class") == "contains-task-list":
277
+ self._output.append("<ac:task-list>")
278
+ self._list_stack.append("task-list")
279
+ else:
280
+ self._output.append("<ul>")
281
+ self._list_stack.append("ul")
258
282
  return idx + 1
259
283
 
260
284
  def _render_bullet_list_close(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
261
- self._output.append("</ul>")
285
+ if self._list_stack and self._list_stack[-1] == "task-list":
286
+ self._output.append("</ac:task-list>")
287
+ else:
288
+ self._output.append("</ul>")
262
289
  if self._list_stack:
263
290
  self._list_stack.pop()
264
291
  return idx + 1
265
292
 
266
293
  def _render_list_item_open(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
267
- self._output.append("<li>")
294
+ token = tokens[idx]
295
+ attrs = token.attrs or {}
296
+ if isinstance(attrs, dict) and attrs.get("class") == "task-list-item":
297
+ self._task_id += 1
298
+ checked = self._is_task_checked(tokens, idx)
299
+ status = "complete" if checked else "incomplete"
300
+ self._output.append("<ac:task>")
301
+ self._output.append(f"<ac:task-id>{self._task_id}</ac:task-id>")
302
+ self._output.append(f"<ac:task-status>{status}</ac:task-status>")
303
+ self._output.append("<ac:task-body>")
304
+ else:
305
+ self._output.append("<li>")
268
306
  return idx + 1
269
307
 
270
308
  def _render_list_item_close(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
271
- self._output.append("</li>")
309
+ if self._list_stack and self._list_stack[-1] == "task-list":
310
+ self._output.append("</ac:task-body></ac:task>")
311
+ else:
312
+ self._output.append("</li>")
272
313
  return idx + 1
273
314
 
315
+ @staticmethod
316
+ def _is_task_checked(tokens: list[Token], list_item_idx: int) -> bool:
317
+ """Look ahead from list_item_open to find the checkbox html_inline and check state."""
318
+ for i in range(list_item_idx + 1, min(list_item_idx + 10, len(tokens))):
319
+ tok = tokens[i]
320
+ if tok.type == "list_item_close":
321
+ break
322
+ if tok.type == "inline" and tok.children:
323
+ for child in tok.children:
324
+ if child.type == "html_inline" and "checkbox" in (child.content or ""):
325
+ return 'checked="checked"' in child.content or "checked" in child.content.split()
326
+ return False
327
+
274
328
  # ------------------------------------------------------------------
275
329
  # Tables
276
330
  # ------------------------------------------------------------------
@@ -340,14 +394,186 @@ class ConfluenceRenderer:
340
394
  return idx + 1
341
395
 
342
396
  def _inline_html_inline(self, token: Token) -> None:
397
+ # Suppress task-list checkboxes (already handled by _render_list_item_open)
398
+ if "task-list-item-checkbox" in (token.content or ""):
399
+ return
343
400
  self._output.append(token.content)
344
401
 
402
+ # ------------------------------------------------------------------
403
+ # Dollar math (inline and block)
404
+ # ------------------------------------------------------------------
405
+
406
+ def _inline_math_inline(self, token: Token) -> None:
407
+ latex = token.content
408
+ self._output.append(
409
+ '<ac:structured-macro ac:name="mathinline">'
410
+ f"<ac:plain-text-body><![CDATA[{latex}]]></ac:plain-text-body>"
411
+ "</ac:structured-macro>"
412
+ )
413
+
414
+ def _render_math_block(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
415
+ latex = tokens[idx].content
416
+ self._output.append(
417
+ '<ac:structured-macro ac:name="mathblock">'
418
+ f"<ac:plain-text-body><![CDATA[{latex}]]></ac:plain-text-body>"
419
+ "</ac:structured-macro>"
420
+ )
421
+ return idx + 1
422
+
423
+ # ------------------------------------------------------------------
424
+ # Definition lists
425
+ # ------------------------------------------------------------------
426
+
427
+ def _render_dl_open(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
428
+ self._output.append("<dl>")
429
+ return idx + 1
430
+
431
+ def _render_dl_close(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
432
+ self._output.append("</dl>")
433
+ return idx + 1
434
+
435
+ def _render_dt_open(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
436
+ self._output.append("<dt>")
437
+ return idx + 1
438
+
439
+ def _render_dt_close(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
440
+ self._output.append("</dt>")
441
+ return idx + 1
442
+
443
+ def _render_dd_open(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
444
+ self._output.append("<dd>")
445
+ return idx + 1
446
+
447
+ def _render_dd_close(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
448
+ self._output.append("</dd>")
449
+ return idx + 1
450
+
451
+ # ------------------------------------------------------------------
452
+ # Footnotes
453
+ # ------------------------------------------------------------------
454
+
455
+ def _inline_footnote_ref(self, token: Token) -> None:
456
+ meta_id = token.meta.get("id", 0) if token.meta else 0
457
+ # Display number is 1-based
458
+ display_num = meta_id + 1
459
+ self._footnote_refs[meta_id] = display_num
460
+ self._output.append(
461
+ f'<sup><a id="footnote-ref-{display_num}" href="#footnote-{display_num}">'
462
+ f"[{display_num}]</a></sup>"
463
+ )
464
+
465
+ def _render_footnote_block_open(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
466
+ self._output.append("<hr /><ol>")
467
+ return idx + 1
468
+
469
+ def _render_footnote_block_close(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
470
+ self._output.append("</ol>")
471
+ return idx + 1
472
+
473
+ def _render_footnote_open(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
474
+ meta_id = tokens[idx].meta.get("id", 0) if tokens[idx].meta else 0
475
+ display_num = meta_id + 1
476
+ self._output.append(f'<li id="footnote-{display_num}">')
477
+ return idx + 1
478
+
479
+ def _render_footnote_close(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
480
+ self._output.append("</li>")
481
+ return idx + 1
482
+
483
+ def _render_footnote_anchor(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
484
+ meta_id = tokens[idx].meta.get("id", 0) if tokens[idx].meta else 0
485
+ display_num = meta_id + 1
486
+ self._output.append(
487
+ f' <a href="#footnote-ref-{display_num}">\u21a9</a>'
488
+ )
489
+ return idx + 1
490
+
491
+ # ------------------------------------------------------------------
492
+ # Front matter (silently stripped)
493
+ # ------------------------------------------------------------------
494
+
495
+ def _render_front_matter(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
496
+ return idx + 1 # no output
497
+
498
+ # ------------------------------------------------------------------
499
+ # Container: panel
500
+ # ------------------------------------------------------------------
501
+
502
+ def _render_container_panel_open(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
503
+ info = tokens[idx].info.strip() if tokens[idx].info else ""
504
+ # info is e.g. "panel My Title" — strip the container name prefix
505
+ title = info[len("panel"):].strip() if info.startswith("panel") else info
506
+ self._output.append('<ac:structured-macro ac:name="panel">')
507
+ if title:
508
+ self._output.append(f'<ac:parameter ac:name="title">{escape(title)}</ac:parameter>')
509
+ self._output.append("<ac:rich-text-body>")
510
+ return idx + 1
511
+
512
+ def _render_container_panel_close(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
513
+ self._output.append("</ac:rich-text-body></ac:structured-macro>")
514
+ return idx + 1
515
+
516
+ # ------------------------------------------------------------------
517
+ # Container: expand
518
+ # ------------------------------------------------------------------
519
+
520
+ def _render_container_expand_open(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
521
+ info = tokens[idx].info.strip() if tokens[idx].info else ""
522
+ title = info[len("expand"):].strip() if info.startswith("expand") else info
523
+ self._output.append('<ac:structured-macro ac:name="expand">')
524
+ if title:
525
+ self._output.append(f'<ac:parameter ac:name="title">{escape(title)}</ac:parameter>')
526
+ self._output.append("<ac:rich-text-body>")
527
+ return idx + 1
528
+
529
+ def _render_container_expand_close(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
530
+ self._output.append("</ac:rich-text-body></ac:structured-macro>")
531
+ return idx + 1
532
+
533
+ # ------------------------------------------------------------------
534
+ # Container: layout
535
+ # ------------------------------------------------------------------
536
+
537
+ def _render_container_layout_open(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
538
+ info = tokens[idx].info.strip() if tokens[idx].info else ""
539
+ layout_type = info[len("layout"):].strip() if info.startswith("layout") else info
540
+ # Convert hyphens to underscores for Confluence ac:type
541
+ layout_type = layout_type.replace("-", "_") if layout_type else "single"
542
+ self._output.append(f'<ac:layout><ac:layout-section ac:type="{escape(layout_type)}">')
543
+ return idx + 1
544
+
545
+ def _render_container_layout_close(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
546
+ self._output.append("</ac:layout-section></ac:layout>")
547
+ return idx + 1
548
+
549
+ # ------------------------------------------------------------------
550
+ # Container: cell
551
+ # ------------------------------------------------------------------
552
+
553
+ def _render_container_cell_open(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
554
+ self._output.append("<ac:layout-cell>")
555
+ return idx + 1
556
+
557
+ def _render_container_cell_close(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
558
+ self._output.append("</ac:layout-cell>")
559
+ return idx + 1
560
+
345
561
 
346
562
  def _create_parser() -> MarkdownIt:
347
563
  """Create a configured markdown-it-py parser."""
348
564
  md = MarkdownIt("commonmark", {"html": True})
349
565
  md.enable("table")
350
566
  md.enable("strikethrough")
567
+ # mdit-py-plugins
568
+ tasklists_plugin(md)
569
+ dollarmath_plugin(md)
570
+ deflist_plugin(md)
571
+ footnote_plugin(md)
572
+ front_matter_plugin(md)
573
+ container_plugin(md, name="panel")
574
+ container_plugin(md, name="expand")
575
+ container_plugin(md, name="layout")
576
+ container_plugin(md, name="cell")
351
577
  return md
352
578
 
353
579
 
@@ -382,6 +382,38 @@ def build_guide() -> dict[str, Any]:
382
382
  ),
383
383
  ],
384
384
  },
385
+ "markdown_support": {
386
+ "description": "Markdown features converted to native Confluence Storage Format.",
387
+ "base": "CommonMark with GitHub-flavored extensions",
388
+ "features": {
389
+ "headings": "# h1 through ###### h6 → <h1>–<h6>",
390
+ "bold_italic": "**bold**, *italic* → <strong>, <em>",
391
+ "strikethrough": "~~text~~ → <del>",
392
+ "inline_code": "`code` → <code>",
393
+ "code_blocks": "```lang ... ``` → ac:structured-macro code with language param",
394
+ "links": "[text](url) → <a href>",
395
+ "images": "![alt](path) → ac:image (local files uploaded as attachments)",
396
+ "tables": "GFM tables → <table>",
397
+ "lists": "Ordered and unordered, nested → <ol>, <ul>",
398
+ "blockquotes": "> text → <blockquote>",
399
+ "admonitions": "> [!NOTE|TIP|WARNING|CAUTION|IMPORTANT] → info/tip/warning/note macros",
400
+ "task_lists": "- [ ] / - [x] → ac:task-list with ac:task elements",
401
+ "math_inline": "$LaTeX$ → ac:structured-macro mathinline",
402
+ "math_block": "$$...$$ → ac:structured-macro mathblock",
403
+ "definition_lists": "Term\\n: Definition → <dl><dt><dd>",
404
+ "footnotes": "[^1] + [^1]: text → superscript links with numbered list",
405
+ "front_matter": "---\\nyaml\\n--- → silently stripped",
406
+ "panels": "::: panel Title\\ncontent\\n::: → ac:structured-macro panel",
407
+ "expand": "::: expand Title\\ncontent\\n::: → ac:structured-macro expand",
408
+ "layouts": ":::: layout two-equal\\n::: cell\\n...\\n::::\\n → ac:layout with ac:layout-section",
409
+ },
410
+ "layout_types": ["single", "two-equal", "two-left-sidebar", "two-right-sidebar", "three-equal", "three-with-sidebars"],
411
+ "agent_hint": (
412
+ "All features are always-on — the parser simply ignores syntax that isn't used. "
413
+ "Math macros require the Confluence LaTeX Math plugin to be installed on the server. "
414
+ "Layouts use :::: (4 colons) for the outer layout block and ::: (3 colons) for inner cells."
415
+ ),
416
+ },
385
417
  "assertions": {
386
418
  "description": "Post-condition assertions verified by plan.verify.",
387
419
  "file_format": "JSON array of assertion objects, or embedded in confpub.yaml under the 'assertions' key.",
@@ -43,17 +43,46 @@ class ConfluenceMarkdownConverter(MarkdownConverter):
43
43
  kwargs.setdefault("heading_style", "ATX")
44
44
  kwargs.setdefault("bullets", "-")
45
45
  kwargs.setdefault("code_language", "")
46
- kwargs.setdefault("strip", ["span"])
46
+ # Don't strip spans — we use <span> for inline macros like mathinline.
47
+ # Regular spans are handled in convert_span by returning raw text.
47
48
  super().__init__(**kwargs)
48
49
 
50
+ # ------------------------------------------------------------------
51
+ # Definition list support
52
+ # ------------------------------------------------------------------
53
+
54
+ def convert_dl(self, el: Tag, text: str, parent_tags: set | None = None) -> str:
55
+ return "\n\n" + text.strip() + "\n\n"
56
+
57
+ def convert_dt(self, el: Tag, text: str, parent_tags: set | None = None) -> str:
58
+ return text.strip() + "\n"
59
+
60
+ def convert_dd(self, el: Tag, text: str, parent_tags: set | None = None) -> str:
61
+ return ": " + text.strip() + "\n"
62
+
49
63
  # ------------------------------------------------------------------
50
64
  # Confluence macro handlers (dispatched via data-confluence-macro attr)
51
65
  # ------------------------------------------------------------------
52
66
 
67
+ def convert_span(self, el: Tag, text: str, parent_tags: set | None = None) -> str:
68
+ macro_name = el.get("data-confluence-macro")
69
+ if macro_name:
70
+ return self._convert_macro(el, text, str(macro_name))
71
+ return text
72
+
53
73
  def convert_div(self, el: Tag, text: str, parent_tags: set | None = None) -> str:
54
74
  macro_name = el.get("data-confluence-macro")
55
75
  if macro_name:
56
76
  return self._convert_macro(el, text, str(macro_name))
77
+ # Layout containers (from _preprocess_storage_format)
78
+ layout_macro = el.get("data-layout-macro")
79
+ if layout_macro == "layout":
80
+ layout_type = el.get("data-layout-type", "single")
81
+ inner = text.strip()
82
+ return f"\n\n:::: layout {layout_type}\n{inner}\n::::\n\n"
83
+ if layout_macro == "cell":
84
+ inner = text.strip()
85
+ return f"\n::: cell\n{inner}\n:::\n"
57
86
  return text
58
87
 
59
88
  def _convert_macro(self, el: Tag, text: str, macro_name: str) -> str:
@@ -61,6 +90,14 @@ class ConfluenceMarkdownConverter(MarkdownConverter):
61
90
  return self._convert_code_macro(el)
62
91
  if macro_name in REVERSE_ADMONITION_MAP:
63
92
  return self._convert_admonition_macro(el, text, macro_name)
93
+ if macro_name == "mathinline":
94
+ return self._convert_mathinline_macro(el)
95
+ if macro_name == "mathblock":
96
+ return self._convert_mathblock_macro(el)
97
+ if macro_name == "panel":
98
+ return self._convert_panel_macro(el)
99
+ if macro_name == "expand":
100
+ return self._convert_expand_macro(el)
64
101
  # Unknown macro
65
102
  self._unknown_macros.append(macro_name)
66
103
  self._warnings.append(f"Unknown macro '{macro_name}' converted to HTML comment")
@@ -68,6 +105,46 @@ class ConfluenceMarkdownConverter(MarkdownConverter):
68
105
  param_str = f" params={params}" if params else ""
69
106
  return f"\n\n<!-- confluence-macro: {macro_name}{param_str} -->\n\n"
70
107
 
108
+ def _convert_mathinline_macro(self, el: Tag) -> str:
109
+ code_el = el.find("pre", class_="confluence-code-body")
110
+ latex = code_el.get_text() if code_el else el.get_text()
111
+ return f"${latex}$"
112
+
113
+ def _convert_mathblock_macro(self, el: Tag) -> str:
114
+ code_el = el.find("pre", class_="confluence-code-body")
115
+ latex = code_el.get_text() if code_el else el.get_text()
116
+ return f"\n\n$$\n{latex}\n$$\n\n"
117
+
118
+ def _convert_panel_macro(self, el: Tag) -> str:
119
+ params = el.get("data-macro-params", "") or ""
120
+ title = ""
121
+ for part in str(params).split("; "):
122
+ if part.startswith("title="):
123
+ title = part[len("title="):]
124
+ break
125
+ body_el = el.find("div", class_="confluence-rich-text-body")
126
+ if body_el:
127
+ body_text = self.convert(str(body_el)).strip()
128
+ else:
129
+ body_text = el.get_text().strip()
130
+ header = f"panel {title}" if title else "panel"
131
+ return f"\n\n::: {header}\n{body_text}\n:::\n\n"
132
+
133
+ def _convert_expand_macro(self, el: Tag) -> str:
134
+ params = el.get("data-macro-params", "") or ""
135
+ title = ""
136
+ for part in str(params).split("; "):
137
+ if part.startswith("title="):
138
+ title = part[len("title="):]
139
+ break
140
+ body_el = el.find("div", class_="confluence-rich-text-body")
141
+ if body_el:
142
+ body_text = self.convert(str(body_el)).strip()
143
+ else:
144
+ body_text = el.get_text().strip()
145
+ header = f"expand {title}" if title else "expand"
146
+ return f"\n\n::: {header}\n{body_text}\n:::\n\n"
147
+
71
148
  def _convert_code_macro(self, el: Tag) -> str:
72
149
  language = el.get("data-macro-language", "") or ""
73
150
  # The code content is in the pre-processed plain text
@@ -139,10 +216,15 @@ def _preprocess_storage_format(html: str) -> tuple[BeautifulSoup, list[str]]:
139
216
  warnings: list[str] = []
140
217
  soup = BeautifulSoup(html, "html.parser")
141
218
 
142
- # 1. Transform ac:structured-macro div[data-confluence-macro]
219
+ # Inline macro names that should become <span> not <div> to preserve
220
+ # surrounding whitespace when markdownify processes them.
221
+ _INLINE_MACROS = {"mathinline"}
222
+
223
+ # 1. Transform ac:structured-macro → div/span[data-confluence-macro]
143
224
  for macro in soup.find_all("ac:structured-macro"):
144
225
  macro_name = macro.get("ac:name", "unknown")
145
- div = soup.new_tag("div")
226
+ tag_name = "span" if macro_name in _INLINE_MACROS else "div"
227
+ div = soup.new_tag(tag_name)
146
228
  div["data-confluence-macro"] = macro_name
147
229
 
148
230
  # Extract parameters
@@ -199,7 +281,84 @@ def _preprocess_storage_format(html: str) -> tuple[BeautifulSoup, list[str]]:
199
281
 
200
282
  img_macro.replace_with(img)
201
283
 
202
- # 3. Transform ac:linka
284
+ # 3. Transform ac:task-listul with checkbox text
285
+ for task_list in soup.find_all("ac:task-list"):
286
+ ul = soup.new_tag("ul")
287
+ for task in task_list.find_all("ac:task", recursive=False):
288
+ li = soup.new_tag("li")
289
+ status_el = task.find("ac:task-status")
290
+ status = status_el.get_text().strip() if status_el else "incomplete"
291
+ checkbox = "[x] " if status == "complete" else "[ ] "
292
+ body_el = task.find("ac:task-body")
293
+ body_text = body_el.get_text().strip() if body_el else ""
294
+ li.string = checkbox + body_text
295
+ ul.append(li)
296
+ task_list.replace_with(ul)
297
+
298
+ # 4. Transform footnote markup
299
+ # Footnote refs: <sup><a href="#footnote-N">[N]</a></sup>
300
+ for sup in soup.find_all("sup"):
301
+ a_tag = sup.find("a")
302
+ if a_tag and a_tag.get("href", "").startswith("#footnote-"):
303
+ text = a_tag.get_text()
304
+ # e.g. "[1]" → "[^1]"
305
+ if text.startswith("[") and text.endswith("]"):
306
+ num = text[1:-1]
307
+ sup.replace_with(f"[^{num}]")
308
+
309
+ # Footnote definitions: detect by id="footnote-N" OR by back-link pattern
310
+ # (Confluence strips id attrs, so we fall back to detecting back-links)
311
+ _footnote_back_re = re.compile(r"#footnote-ref-(\d+)")
312
+ footnote_lis_found = False
313
+ for li in soup.find_all("li"):
314
+ li_id = li.get("id", "")
315
+ num = None
316
+ if li_id.startswith("footnote-"):
317
+ num = li_id[len("footnote-"):]
318
+ else:
319
+ # Fallback: detect by back-link <a href="#footnote-ref-N">
320
+ for back_a in li.find_all("a"):
321
+ m = _footnote_back_re.search(back_a.get("href", ""))
322
+ if m:
323
+ num = m.group(1)
324
+ break
325
+ if num:
326
+ footnote_lis_found = True
327
+ # Remove back-links
328
+ for back_a in li.find_all("a"):
329
+ if _footnote_back_re.search(back_a.get("href", "")):
330
+ back_a.decompose()
331
+ text = li.get_text().strip()
332
+ p = soup.new_tag("p")
333
+ p.string = f"[^{num}]: {text}"
334
+ li.replace_with(p)
335
+
336
+ # Remove <hr> before footnote <ol> (the separator)
337
+ if footnote_lis_found:
338
+ for hr in soup.find_all("hr"):
339
+ next_sib = hr.find_next_sibling()
340
+ if next_sib and next_sib.name == "ol":
341
+ hr.decompose()
342
+
343
+ # 5. Transform ac:layout → div[data-layout-macro]
344
+ for layout in soup.find_all("ac:layout"):
345
+ layout_div = soup.new_tag("div")
346
+ layout_div["data-layout-macro"] = "layout"
347
+ section = layout.find("ac:layout-section")
348
+ if section:
349
+ layout_type = section.get("ac:type", "single")
350
+ # Convert underscores back to hyphens
351
+ layout_type = layout_type.replace("_", "-")
352
+ layout_div["data-layout-type"] = layout_type
353
+ for cell in section.find_all("ac:layout-cell", recursive=False):
354
+ cell_div = soup.new_tag("div")
355
+ cell_div["data-layout-macro"] = "cell"
356
+ for child in list(cell.children):
357
+ cell_div.append(child.extract())
358
+ layout_div.append(cell_div)
359
+ layout.replace_with(layout_div)
360
+
361
+ # 6. Transform ac:link → a
203
362
  for link in soup.find_all("ac:link"):
204
363
  a = soup.new_tag("a")
205
364
 
@@ -248,3 +248,174 @@ class TestExtractH1Title:
248
248
  def test_h2_before_h1(self):
249
249
  md = "## Intro\n\n# Main Title"
250
250
  assert extract_h1_title(md) == "Main Title"
251
+
252
+
253
+ class TestTaskLists:
254
+ def test_unchecked_task(self):
255
+ result = convert_markdown("- [ ] Buy milk")
256
+ assert "<ac:task-list>" in result
257
+ assert "<ac:task>" in result
258
+ assert "<ac:task-status>incomplete</ac:task-status>" in result
259
+ assert "Buy milk" in result
260
+ assert "</ac:task-body></ac:task>" in result
261
+ assert "</ac:task-list>" in result
262
+
263
+ def test_checked_task(self):
264
+ result = convert_markdown("- [x] Buy milk")
265
+ assert "<ac:task-status>complete</ac:task-status>" in result
266
+
267
+ def test_mixed_task_list(self):
268
+ md = "- [ ] First\n- [x] Second\n- [ ] Third"
269
+ result = convert_markdown(md)
270
+ assert result.count("<ac:task>") == 3
271
+ assert "<ac:task-status>incomplete</ac:task-status>" in result
272
+ assert "<ac:task-status>complete</ac:task-status>" in result
273
+
274
+ def test_task_ids_increment(self):
275
+ md = "- [ ] A\n- [ ] B\n- [ ] C"
276
+ result = convert_markdown(md)
277
+ assert "<ac:task-id>1</ac:task-id>" in result
278
+ assert "<ac:task-id>2</ac:task-id>" in result
279
+ assert "<ac:task-id>3</ac:task-id>" in result
280
+
281
+ def test_regular_list_unchanged(self):
282
+ result = convert_markdown("- Normal item\n- Another item")
283
+ assert "<ul>" in result
284
+ assert "<li>" in result
285
+ assert "<ac:task" not in result
286
+
287
+ def test_no_input_checkbox_in_output(self):
288
+ result = convert_markdown("- [ ] Task")
289
+ assert "<input" not in result
290
+
291
+
292
+ class TestMath:
293
+ def test_inline_math(self):
294
+ result = convert_markdown("The equation $E=mc^2$ is famous.")
295
+ assert '<ac:structured-macro ac:name="mathinline">' in result
296
+ assert "<![CDATA[E=mc^2]]>" in result
297
+ assert "</ac:structured-macro>" in result
298
+
299
+ def test_block_math(self):
300
+ result = convert_markdown("$$\nx^2 + y^2 = z^2\n$$")
301
+ assert '<ac:structured-macro ac:name="mathblock">' in result
302
+ assert "x^2 + y^2 = z^2" in result
303
+
304
+ def test_inline_math_in_paragraph(self):
305
+ result = convert_markdown("Given $a$ and $b$, compute $a+b$.")
306
+ assert result.count('ac:name="mathinline"') == 3
307
+
308
+
309
+ class TestDefinitionLists:
310
+ def test_basic_deflist(self):
311
+ md = "Term\n: Definition here"
312
+ result = convert_markdown(md)
313
+ assert "<dl>" in result
314
+ assert "<dt>" in result
315
+ assert "Term" in result
316
+ assert "<dd>" in result
317
+ assert "Definition here" in result
318
+ assert "</dl>" in result
319
+
320
+ def test_multiple_terms(self):
321
+ md = "Apple\n: A fruit\n\nBanana\n: Another fruit"
322
+ result = convert_markdown(md)
323
+ assert result.count("<dt>") == 2
324
+ assert result.count("<dd>") == 2
325
+
326
+ def test_multiple_definitions(self):
327
+ md = "Term\n: First def\n: Second def"
328
+ result = convert_markdown(md)
329
+ assert result.count("<dd>") == 2
330
+
331
+
332
+ class TestFootnotes:
333
+ def test_footnote_ref_link(self):
334
+ md = "Text with a footnote[^1].\n\n[^1]: Footnote content."
335
+ result = convert_markdown(md)
336
+ assert '<sup><a id="footnote-ref-1" href="#footnote-1">[1]</a></sup>' in result
337
+
338
+ def test_footnote_body(self):
339
+ md = "Text[^1].\n\n[^1]: Footnote content."
340
+ result = convert_markdown(md)
341
+ assert '<li id="footnote-1">' in result
342
+ assert "Footnote content." in result
343
+
344
+ def test_footnote_back_link(self):
345
+ md = "Text[^1].\n\n[^1]: Footnote content."
346
+ result = convert_markdown(md)
347
+ assert 'href="#footnote-ref-1"' in result
348
+ assert "\u21a9" in result
349
+
350
+ def test_multiple_footnotes(self):
351
+ md = "First[^1] and second[^2].\n\n[^1]: Note one.\n[^2]: Note two."
352
+ result = convert_markdown(md)
353
+ assert "footnote-1" in result
354
+ assert "footnote-2" in result
355
+ assert result.count("<ac:task") == 0 # no task contamination
356
+
357
+
358
+ class TestFrontMatter:
359
+ def test_front_matter_stripped(self):
360
+ md = "---\ntitle: My Page\ntags: [a, b]\n---\n\n# Hello"
361
+ result = convert_markdown(md)
362
+ assert "title:" not in result
363
+ assert "tags:" not in result
364
+ assert "<h1>" in result
365
+ assert "Hello" in result
366
+
367
+ def test_no_regression_without_front_matter(self):
368
+ result = convert_markdown("# Hello\n\nWorld")
369
+ assert "<h1>" in result
370
+ assert "Hello" in result
371
+ assert "World" in result
372
+
373
+
374
+ class TestContainerPanel:
375
+ def test_panel_with_title(self):
376
+ md = "::: panel Important Note\nThis is panel content.\n:::"
377
+ result = convert_markdown(md)
378
+ assert '<ac:structured-macro ac:name="panel">' in result
379
+ assert '<ac:parameter ac:name="title">Important Note</ac:parameter>' in result
380
+ assert "<ac:rich-text-body>" in result
381
+ assert "This is panel content." in result
382
+ assert "</ac:rich-text-body></ac:structured-macro>" in result
383
+
384
+ def test_panel_without_title(self):
385
+ md = "::: panel\nJust content.\n:::"
386
+ result = convert_markdown(md)
387
+ assert '<ac:structured-macro ac:name="panel">' in result
388
+ assert 'ac:name="title"' not in result
389
+ assert "Just content." in result
390
+
391
+
392
+ class TestContainerExpand:
393
+ def test_expand_with_title(self):
394
+ md = "::: expand Click to expand\nHidden content here.\n:::"
395
+ result = convert_markdown(md)
396
+ assert '<ac:structured-macro ac:name="expand">' in result
397
+ assert '<ac:parameter ac:name="title">Click to expand</ac:parameter>' in result
398
+ assert "Hidden content here." in result
399
+
400
+
401
+ class TestContainerLayout:
402
+ def test_two_equal_layout(self):
403
+ md = ":::: layout two-equal\n::: cell\nLeft column\n:::\n::: cell\nRight column\n:::\n::::"
404
+ result = convert_markdown(md)
405
+ assert '<ac:layout><ac:layout-section ac:type="two_equal">' in result
406
+ assert "<ac:layout-cell>" in result
407
+ assert "Left column" in result
408
+ assert "Right column" in result
409
+ assert "</ac:layout-cell>" in result
410
+ assert "</ac:layout-section></ac:layout>" in result
411
+
412
+ def test_single_column_layout(self):
413
+ md = ":::: layout single\n::: cell\nOnly column\n:::\n::::"
414
+ result = convert_markdown(md)
415
+ assert 'ac:type="single"' in result
416
+
417
+ def test_three_column_layout(self):
418
+ md = ":::: layout three-equal\n::: cell\nA\n:::\n::: cell\nB\n:::\n::: cell\nC\n:::\n::::"
419
+ result = convert_markdown(md)
420
+ assert 'ac:type="three_equal"' in result
421
+ assert result.count("<ac:layout-cell>") == 3
@@ -248,6 +248,158 @@ class TestMixedContent:
248
248
  assert len(result.unknown_macros) == 0
249
249
 
250
250
 
251
+ class TestTaskListReverse:
252
+ def test_unchecked_task(self):
253
+ html = (
254
+ '<ac:task-list><ac:task>'
255
+ '<ac:task-id>1</ac:task-id>'
256
+ '<ac:task-status>incomplete</ac:task-status>'
257
+ '<ac:task-body>Buy milk</ac:task-body>'
258
+ '</ac:task></ac:task-list>'
259
+ )
260
+ result = convert_storage_to_markdown(html)
261
+ assert "[ ] Buy milk" in result.markdown
262
+
263
+ def test_checked_task(self):
264
+ html = (
265
+ '<ac:task-list><ac:task>'
266
+ '<ac:task-id>1</ac:task-id>'
267
+ '<ac:task-status>complete</ac:task-status>'
268
+ '<ac:task-body>Done item</ac:task-body>'
269
+ '</ac:task></ac:task-list>'
270
+ )
271
+ result = convert_storage_to_markdown(html)
272
+ assert "[x] Done item" in result.markdown
273
+
274
+
275
+ class TestMathReverse:
276
+ def test_inline_math(self):
277
+ html = (
278
+ '<ac:structured-macro ac:name="mathinline">'
279
+ '<ac:plain-text-body><![CDATA[E=mc^2]]></ac:plain-text-body>'
280
+ '</ac:structured-macro>'
281
+ )
282
+ result = convert_storage_to_markdown(html)
283
+ assert "$E=mc^2$" in result.markdown
284
+
285
+ def test_block_math(self):
286
+ html = (
287
+ '<ac:structured-macro ac:name="mathblock">'
288
+ '<ac:plain-text-body><![CDATA[x^2 + y^2 = z^2]]></ac:plain-text-body>'
289
+ '</ac:structured-macro>'
290
+ )
291
+ result = convert_storage_to_markdown(html)
292
+ assert "$$" in result.markdown
293
+ assert "x^2 + y^2 = z^2" in result.markdown
294
+
295
+
296
+ class TestDefinitionListReverse:
297
+ def test_basic_deflist(self):
298
+ html = "<dl><dt>Term</dt><dd>Definition here</dd></dl>"
299
+ result = convert_storage_to_markdown(html)
300
+ assert "Term" in result.markdown
301
+ assert ": Definition here" in result.markdown
302
+
303
+
304
+ class TestFootnoteReverse:
305
+ def test_footnote_ref_and_def(self):
306
+ html = (
307
+ '<p>Text with <sup><a id="footnote-ref-1" href="#footnote-1">[1]</a></sup>.</p>'
308
+ '<hr /><ol><li id="footnote-1">Footnote text. '
309
+ '<a href="#footnote-ref-1">\u21a9</a></li></ol>'
310
+ )
311
+ result = convert_storage_to_markdown(html)
312
+ assert "[^1]" in result.markdown
313
+ assert "[^1]: Footnote text." in result.markdown
314
+
315
+
316
+ class TestPanelReverse:
317
+ def test_panel_with_title(self):
318
+ html = (
319
+ '<ac:structured-macro ac:name="panel">'
320
+ '<ac:parameter ac:name="title">My Panel</ac:parameter>'
321
+ '<ac:rich-text-body><p>Panel content.</p></ac:rich-text-body>'
322
+ '</ac:structured-macro>'
323
+ )
324
+ result = convert_storage_to_markdown(html)
325
+ assert "::: panel My Panel" in result.markdown
326
+ assert "Panel content." in result.markdown
327
+ assert ":::" in result.markdown
328
+
329
+
330
+ class TestExpandReverse:
331
+ def test_expand_with_title(self):
332
+ html = (
333
+ '<ac:structured-macro ac:name="expand">'
334
+ '<ac:parameter ac:name="title">Click me</ac:parameter>'
335
+ '<ac:rich-text-body><p>Hidden content.</p></ac:rich-text-body>'
336
+ '</ac:structured-macro>'
337
+ )
338
+ result = convert_storage_to_markdown(html)
339
+ assert "::: expand Click me" in result.markdown
340
+ assert "Hidden content." in result.markdown
341
+
342
+
343
+ class TestLayoutReverse:
344
+ def test_layout_with_cells(self):
345
+ html = (
346
+ '<ac:layout><ac:layout-section ac:type="two_equal">'
347
+ '<ac:layout-cell><p>Left</p></ac:layout-cell>'
348
+ '<ac:layout-cell><p>Right</p></ac:layout-cell>'
349
+ '</ac:layout-section></ac:layout>'
350
+ )
351
+ result = convert_storage_to_markdown(html)
352
+ assert ":::: layout two-equal" in result.markdown
353
+ assert "::: cell" in result.markdown
354
+ assert "Left" in result.markdown
355
+ assert "Right" in result.markdown
356
+ assert "::::" in result.markdown
357
+
358
+
359
+ class TestPluginRoundTrips:
360
+ """Test round-trip conversion for new plugin features."""
361
+
362
+ def test_task_list_round_trip(self):
363
+ from confpub.converter import convert_markdown
364
+ md = "- [ ] Todo\n- [x] Done\n"
365
+ storage = convert_markdown(md)
366
+ result = convert_storage_to_markdown(storage)
367
+ assert "[ ] Todo" in result.markdown
368
+ assert "[x] Done" in result.markdown
369
+
370
+ def test_deflist_round_trip(self):
371
+ from confpub.converter import convert_markdown
372
+ md = "Apple\n: A fruit\n"
373
+ storage = convert_markdown(md)
374
+ result = convert_storage_to_markdown(storage)
375
+ assert "Apple" in result.markdown
376
+ assert ": A fruit" in result.markdown
377
+
378
+ def test_front_matter_stripped_round_trip(self):
379
+ from confpub.converter import convert_markdown
380
+ md = "---\ntitle: Test\n---\n\n# Hello\n"
381
+ storage = convert_markdown(md)
382
+ assert "title:" not in storage
383
+ assert "<h1>" in storage
384
+
385
+ def test_panel_round_trip(self):
386
+ from confpub.converter import convert_markdown
387
+ md = "::: panel My Title\nContent here.\n:::\n"
388
+ storage = convert_markdown(md)
389
+ result = convert_storage_to_markdown(storage)
390
+ assert "panel" in result.markdown.lower()
391
+ assert "Content here." in result.markdown
392
+
393
+ def test_layout_round_trip(self):
394
+ from confpub.converter import convert_markdown
395
+ md = ":::: layout two-equal\n::: cell\nLeft\n:::\n::: cell\nRight\n:::\n::::\n"
396
+ storage = convert_markdown(md)
397
+ result = convert_storage_to_markdown(storage)
398
+ assert "layout" in result.markdown
399
+ assert "Left" in result.markdown
400
+ assert "Right" in result.markdown
401
+
402
+
251
403
  class TestRoundTrip:
252
404
  """Test that content survives a round-trip through conversion."""
253
405
 
@@ -356,8 +356,10 @@ name = "confpub-cli"
356
356
  source = { editable = "." }
357
357
  dependencies = [
358
358
  { name = "atlassian-python-api" },
359
+ { name = "beautifulsoup4" },
359
360
  { name = "keyring" },
360
361
  { name = "markdown-it-py", extra = ["linkify", "plugins"] },
362
+ { name = "markdownify" },
361
363
  { name = "orjson" },
362
364
  { name = "pydantic" },
363
365
  { name = "pyyaml" },
@@ -374,15 +376,17 @@ dev = [
374
376
  [package.metadata]
375
377
  requires-dist = [
376
378
  { name = "atlassian-python-api", specifier = ">=3.41" },
379
+ { name = "beautifulsoup4", specifier = ">=4.12" },
377
380
  { name = "hatch", marker = "extra == 'dev'", specifier = ">=1.0" },
378
381
  { name = "keyring", specifier = ">=24.0" },
379
382
  { name = "markdown-it-py", extras = ["linkify", "plugins"], specifier = ">=3.0" },
383
+ { name = "markdownify", specifier = ">=0.14" },
380
384
  { name = "orjson", specifier = ">=3.9" },
381
385
  { name = "pydantic", specifier = ">=2.0" },
382
386
  { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" },
383
387
  { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" },
384
388
  { name = "pyyaml", specifier = ">=6.0" },
385
- { name = "typer", extras = ["all"], specifier = ">=0.9" },
389
+ { name = "typer", specifier = ">=0.9" },
386
390
  ]
387
391
  provides-extras = ["dev"]
388
392
 
@@ -823,6 +827,19 @@ plugins = [
823
827
  { name = "mdit-py-plugins" },
824
828
  ]
825
829
 
830
+ [[package]]
831
+ name = "markdownify"
832
+ version = "1.2.2"
833
+ source = { registry = "https://pypi.org/simple" }
834
+ dependencies = [
835
+ { name = "beautifulsoup4" },
836
+ { name = "six" },
837
+ ]
838
+ sdist = { url = "https://files.pythonhosted.org/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09", size = 18816, upload-time = "2025-11-16T19:21:18.565Z" }
839
+ wheels = [
840
+ { url = "https://files.pythonhosted.org/packages/43/ce/f1e3e9d959db134cedf06825fae8d5b294bd368aacdd0831a3975b7c4d55/markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a", size = 15724, upload-time = "2025-11-16T19:21:17.622Z" },
841
+ ]
842
+
826
843
  [[package]]
827
844
  name = "mdit-py-plugins"
828
845
  version = "0.5.0"
@@ -1341,6 +1358,15 @@ wheels = [
1341
1358
  { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
1342
1359
  ]
1343
1360
 
1361
+ [[package]]
1362
+ name = "six"
1363
+ version = "1.17.0"
1364
+ source = { registry = "https://pypi.org/simple" }
1365
+ sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
1366
+ wheels = [
1367
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
1368
+ ]
1369
+
1344
1370
  [[package]]
1345
1371
  name = "soupsieve"
1346
1372
  version = "2.8.3"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes