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.
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/PKG-INFO +11 -2
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/README.md +10 -1
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/__init__.py +1 -1
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/cli.py +40 -13
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/config.py +1 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/converter.py +232 -6
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/errors.py +28 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/guide.py +47 -2
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/reverse_converter.py +163 -4
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_converter.py +171 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_integration.py +104 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_reverse_converter.py +152 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/uv.lock +27 -1
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/.github/workflows/publish.yml +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/.gitignore +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/CLAUDE.md +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/LICENSE +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/PRD.md +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/applier.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/assets.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/confluence.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/envelope.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/lockfile.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/manifest.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/output.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/planner.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/publish.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/puller.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/py.typed +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/validator.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub/verifier.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/confpub.lock +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/pyproject.toml +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/__init__.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/conftest.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_applier.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_assets.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_config.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_confluence.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_envelope.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_errors.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_guide.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_lockfile.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_manifest.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_output.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_planner.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_publish.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_puller.py +0 -0
- {confpub_cli-1.4.3 → confpub_cli-1.5.0}/tests/test_validator.py +0 -0
- {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.
|
|
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
|
|
|
@@ -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(
|
|
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"
|
|
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(
|
|
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
|
|
311
|
-
|
|
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="
|
|
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="
|
|
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="
|
|
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
|
|
681
|
-
|
|
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:
|
|
@@ -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
|
|
|
@@ -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",
|