confpub-cli 1.4.3__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.3 → confpub_cli-1.5.0}/PKG-INFO +11 -2
  2. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/README.md +10 -1
  3. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/__init__.py +1 -1
  4. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/cli.py +40 -13
  5. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/config.py +1 -0
  6. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/converter.py +232 -6
  7. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/errors.py +28 -0
  8. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/guide.py +47 -2
  9. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/reverse_converter.py +163 -4
  10. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_converter.py +171 -0
  11. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_integration.py +104 -0
  12. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_reverse_converter.py +152 -0
  13. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/uv.lock +27 -1
  14. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/.github/workflows/publish.yml +0 -0
  15. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/.gitignore +0 -0
  16. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/CLAUDE.md +0 -0
  17. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/LICENSE +0 -0
  18. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/PRD.md +0 -0
  19. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/applier.py +0 -0
  20. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/assets.py +0 -0
  21. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/confluence.py +0 -0
  22. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/envelope.py +0 -0
  23. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/lockfile.py +0 -0
  24. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/manifest.py +0 -0
  25. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/output.py +0 -0
  26. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/planner.py +0 -0
  27. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/publish.py +0 -0
  28. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/puller.py +0 -0
  29. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/py.typed +0 -0
  30. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/validator.py +0 -0
  31. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/verifier.py +0 -0
  32. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub.lock +0 -0
  33. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/pyproject.toml +0 -0
  34. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/__init__.py +0 -0
  35. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/conftest.py +0 -0
  36. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_applier.py +0 -0
  37. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_assets.py +0 -0
  38. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_config.py +0 -0
  39. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_confluence.py +0 -0
  40. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_envelope.py +0 -0
  41. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_errors.py +0 -0
  42. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_guide.py +0 -0
  43. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_lockfile.py +0 -0
  44. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_manifest.py +0 -0
  45. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_output.py +0 -0
  46. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_planner.py +0 -0
  47. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_publish.py +0 -0
  48. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_puller.py +0 -0
  49. {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_validator.py +0 -0
  50. {confpub_cli-1.4.3 → 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.3
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.3"
3
+ __version__ = "1.5.0"
@@ -13,11 +13,30 @@ from typing import Any, Iterator, Optional
13
13
 
14
14
  import typer
15
15
 
16
+ import os
17
+
16
18
  from confpub import __version__
17
19
  from confpub.envelope import Envelope
18
20
  from confpub.errors import ConfpubError, exit_code_for, ERR_INTERNAL_SDK
19
21
  from confpub.output import emit_stderr, emit_stdout, is_compact, is_verbose, set_compact, set_quiet, set_verbose
20
22
 
23
+
24
+ def _resolve_space(cli_space: str | None, required: bool = False) -> str | None:
25
+ """Resolve space from CLI flag or CONFPUB_SPACE env var, with validation."""
26
+ from confpub.config import ENV_SPACE
27
+ from confpub.errors import validate_space_key
28
+
29
+ space = cli_space or os.environ.get(ENV_SPACE)
30
+ if space is not None:
31
+ validate_space_key(space)
32
+ return space
33
+ if required:
34
+ raise ConfpubError(
35
+ "ERR_VALIDATION_REQUIRED",
36
+ "Space key is required. Use --space or set CONFPUB_SPACE.",
37
+ )
38
+ return None
39
+
21
40
  # ---------------------------------------------------------------------------
22
41
  # Subcommand group apps
23
42
  # ---------------------------------------------------------------------------
@@ -197,12 +216,14 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
197
216
 
198
217
  @page_app.command("list")
199
218
  def page_list(
200
- space: str = typer.Option(..., "--space", help="Confluence space key"),
219
+ space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
201
220
  limit: int = typer.Option(25, "--limit", help="Maximum number of pages to return"),
202
221
  start: int = typer.Option(0, "--start", help="Starting offset for pagination"),
203
222
  ) -> None:
204
223
  """List pages in a Confluence space."""
205
- with command_context("page.list", target={"space": space}) as ctx:
224
+ with command_context("page.list") as ctx:
225
+ space = _resolve_space(space, required=True)
226
+ ctx.target = {"space": space}
206
227
  from confpub.confluence import build_client, _slim_page
207
228
  client = build_client()
208
229
  ctx.client = client
@@ -218,7 +239,7 @@ def page_list(
218
239
 
219
240
  @page_app.command("inspect")
220
241
  def page_inspect(
221
- space: str = typer.Option(None, "--space", help="Confluence space key"),
242
+ space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
222
243
  title: str = typer.Option(None, "--title", help="Page title"),
223
244
  page_id: str = typer.Option(None, "--page-id", help="Confluence page ID"),
224
245
  raw: bool = typer.Option(False, "--raw", help="Return full raw API response"),
@@ -226,6 +247,7 @@ def page_inspect(
226
247
  ) -> None:
227
248
  """Inspect a Confluence page."""
228
249
  with command_context("page.inspect", target={"space": space, "title": title, "page_id": page_id}) as ctx:
250
+ space = _resolve_space(space)
229
251
  from confpub.confluence import build_client, _slim_page
230
252
  client = build_client()
231
253
  ctx.client = client
@@ -258,7 +280,7 @@ def page_inspect(
258
280
  @page_app.command("publish")
259
281
  def page_publish(
260
282
  file: str = typer.Argument(..., help="Markdown file to publish"),
261
- space: str = typer.Option(..., "--space", help="Confluence space key"),
283
+ space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
262
284
  parent: Optional[str] = typer.Option(None, "--parent", help="Parent page title"),
263
285
  title: Optional[str] = typer.Option(None, "--title", help="Page title (defaults to filename stem, hyphen/underscore→spaces, title-cased)"),
264
286
  title_from_h1: bool = typer.Option(False, "--title-from-h1", help="Derive title from first H1 heading in the Markdown file"),
@@ -274,6 +296,8 @@ def page_publish(
274
296
  if page_id:
275
297
  target["page_id"] = page_id
276
298
  with command_context("page.publish", target=target) as ctx:
299
+ space = _resolve_space(space, required=True)
300
+ ctx.target["space"] = space
277
301
  if not page_id and not parent:
278
302
  raise ConfpubError(
279
303
  "ERR_VALIDATION_REQUIRED",
@@ -296,7 +320,7 @@ def page_publish(
296
320
 
297
321
  @page_app.command("pull")
298
322
  def page_pull(
299
- space: str = typer.Option(None, "--space", help="Confluence space key"),
323
+ space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
300
324
  title: str = typer.Option(None, "--title", help="Page title"),
301
325
  page_id: str = typer.Option(None, "--page-id", help="Confluence page ID"),
302
326
  output: str = typer.Option(".", "--output", "-o", help="Output directory"),
@@ -307,8 +331,8 @@ def page_pull(
307
331
  manifest: bool = typer.Option(False, "--manifest", help="Generate confpub.yaml manifest"),
308
332
  ) -> None:
309
333
  """Pull Confluence pages to local Markdown files."""
310
- target = {"space": space, "title": title, "page_id": page_id}
311
- with command_context("page.pull", target=target) as ctx:
334
+ with command_context("page.pull", target={"space": space, "title": title, "page_id": page_id}) as ctx:
335
+ space = _resolve_space(space)
312
336
  from confpub.errors import ERR_VALIDATION_REQUIRED
313
337
  if not page_id and not (space and title):
314
338
  raise ConfpubError(
@@ -333,13 +357,14 @@ def page_pull(
333
357
 
334
358
  @page_app.command("delete")
335
359
  def page_delete(
336
- space: Optional[str] = typer.Option(None, "--space", help="Confluence space key"),
360
+ space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
337
361
  title: Optional[str] = typer.Option(None, "--title", help="Page title"),
338
362
  page_id: Optional[str] = typer.Option(None, "--page-id", help="Confluence page ID"),
339
363
  cascade: bool = typer.Option(False, "--cascade", help="Also delete child pages"),
340
364
  ) -> None:
341
365
  """Delete a Confluence page."""
342
366
  with command_context("page.delete", target={"space": space, "title": title, "page_id": page_id}) as ctx:
367
+ space = _resolve_space(space)
343
368
  if not page_id and not (space and title):
344
369
  raise ConfpubError(
345
370
  "ERR_VALIDATION_REQUIRED",
@@ -384,12 +409,13 @@ def page_delete(
384
409
  def page_move(
385
410
  page_id: str = typer.Option(..., "--page-id", help="Confluence page ID to move"),
386
411
  target_parent: Optional[str] = typer.Option(None, "--target-parent", help="Title of the new parent page"),
387
- space: Optional[str] = typer.Option(None, "--space", help="Space key (required with --target-parent)"),
412
+ space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
388
413
  target_parent_id: Optional[str] = typer.Option(None, "--target-parent-id", help="Page ID of the new parent"),
389
414
  ) -> None:
390
415
  """Move a page under a new parent."""
391
416
  target = {"page_id": page_id}
392
417
  with command_context("page.move", target=target) as ctx:
418
+ space = _resolve_space(space)
393
419
  if not target_parent and not target_parent_id:
394
420
  raise ConfpubError(
395
421
  "ERR_VALIDATION_REQUIRED",
@@ -460,11 +486,12 @@ def attachment_upload(
460
486
  def plan_create(
461
487
  manifest: str = typer.Option(..., "--manifest", help="Path to confpub.yaml manifest"),
462
488
  output: Optional[str] = typer.Option(None, "--output", help="Output path for plan artifact"),
463
- space: Optional[str] = typer.Option(None, "--space", help="Override manifest space"),
489
+ space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
464
490
  parent: Optional[str] = typer.Option(None, "--parent", help="Override manifest parent"),
465
491
  ) -> None:
466
492
  """Generate a plan artifact from a manifest."""
467
493
  with command_context("plan.create", target={"manifest": manifest}) as ctx:
494
+ space = _resolve_space(space)
468
495
  from confpub.planner import create_plan
469
496
  result = create_plan(
470
497
  manifest_path=manifest,
@@ -668,7 +695,7 @@ def comment_add(
668
695
  @app.command("search")
669
696
  def search(
670
697
  cql: Optional[str] = typer.Option(None, "--cql", help="Raw CQL query"),
671
- space: Optional[str] = typer.Option(None, "--space", help="Filter by space key"),
698
+ space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
672
699
  title: Optional[str] = typer.Option(None, "--title", help="Search by page title (fuzzy match)"),
673
700
  content_type: Optional[str] = typer.Option(None, "--type", help="Filter by content type (page, blogpost, etc.)"),
674
701
  limit: int = typer.Option(25, "--limit", help="Maximum results to return"),
@@ -677,8 +704,8 @@ def search(
677
704
  excerpt_length: int = typer.Option(200, "--excerpt-length", help="Max excerpt chars (0 = unlimited)"),
678
705
  ) -> None:
679
706
  """Search Confluence content using CQL."""
680
- target = {"cql": cql, "space": space, "title": title, "type": content_type}
681
- with command_context("search", target=target) as ctx:
707
+ with command_context("search", target={"cql": cql, "space": space, "title": title, "type": content_type}) as ctx:
708
+ space = _resolve_space(space)
682
709
  # Build effective CQL from flags
683
710
  fragments: list[str] = []
684
711
  if space:
@@ -23,6 +23,7 @@ ENV_URL = "CONFPUB_URL"
23
23
  ENV_TOKEN = "CONFPUB_TOKEN"
24
24
  ENV_USER = "CONFPUB_USER"
25
25
  ENV_SSL_VERIFY = "CONFPUB_SSL_VERIFY"
26
+ ENV_SPACE = "CONFPUB_SPACE"
26
27
 
27
28
 
28
29
  class ConfigModel(BaseModel):
@@ -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
 
@@ -16,6 +16,7 @@ ERR_VALIDATION_ASSET_MISSING = "ERR_VALIDATION_ASSET_MISSING"
16
16
  ERR_VALIDATION_SPACE_MISMATCH = "ERR_VALIDATION_SPACE_MISMATCH"
17
17
  ERR_VALIDATION_NOT_FOUND = "ERR_VALIDATION_NOT_FOUND"
18
18
  ERR_VALIDATION_LABEL = "ERR_VALIDATION_LABEL"
19
+ ERR_VALIDATION_SPACE_KEY = "ERR_VALIDATION_SPACE_KEY"
19
20
 
20
21
  # Auth (exit 20)
21
22
  ERR_AUTH_REQUIRED = "ERR_AUTH_REQUIRED"
@@ -169,6 +170,33 @@ def io_error(
169
170
  return ConfpubError(code, message, details=details if details else None)
170
171
 
171
172
 
173
+ def validate_space_key(space: str | None) -> None:
174
+ """Reject space values that look like shell-expanded paths.
175
+
176
+ PowerShell expands unquoted ``~username`` to a Windows home path
177
+ (e.g. ``C:\\Users\\username``). Catching this early gives the caller an
178
+ actionable error instead of a confusing Confluence API failure.
179
+ """
180
+ if space is None:
181
+ return
182
+ import re
183
+ if "\\" in space or "/" in space or re.match(r"^[A-Za-z]:", space):
184
+ raise ConfpubError(
185
+ ERR_VALIDATION_SPACE_KEY,
186
+ (
187
+ f"Space key '{space}' appears to be a shell-expanded path. "
188
+ "Quote the value: --space '~username' or set CONFPUB_SPACE=~username"
189
+ ),
190
+ details={
191
+ "fix_options": [
192
+ "Quote the --space value: --space '~username'",
193
+ "Set the CONFPUB_SPACE environment variable instead",
194
+ "Use the space key without shell-expandable characters",
195
+ ],
196
+ },
197
+ )
198
+
199
+
172
200
  def internal_error(
173
201
  code: str = ERR_INTERNAL_CONVERTER,
174
202
  message: str = "Internal error",