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.
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/PKG-INFO +11 -2
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/README.md +10 -1
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/__init__.py +1 -1
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/converter.py +232 -6
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/guide.py +32 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/reverse_converter.py +163 -4
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_converter.py +171 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_reverse_converter.py +152 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/uv.lock +27 -1
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/.github/workflows/publish.yml +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/.gitignore +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/CLAUDE.md +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/LICENSE +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/PRD.md +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/applier.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/assets.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/cli.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/config.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/confluence.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/envelope.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/errors.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/lockfile.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/manifest.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/output.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/planner.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/publish.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/puller.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/py.typed +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/validator.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub/verifier.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/confpub.lock +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/pyproject.toml +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/__init__.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/conftest.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_applier.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_assets.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_config.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_confluence.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_envelope.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_errors.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_guide.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_integration.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_lockfile.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_manifest.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_output.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_planner.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_publish.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_puller.py +0 -0
- {confpub_cli-1.4.4 → confpub_cli-1.5.0}/tests/test_validator.py +0 -0
- {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.
|
|
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
|
| `` | 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
|
| `` | 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
|
|
|
@@ -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
|
-
|
|
257
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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": " → 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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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:
|
|
284
|
+
# 3. Transform ac:task-list → ul 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",
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|