confpub-cli 1.5.0__tar.gz → 1.7.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.5.0 → confpub_cli-1.7.0}/PKG-INFO +11 -2
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/README.md +10 -1
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/__init__.py +1 -1
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/cli.py +43 -14
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/converter.py +157 -0
- confpub_cli-1.7.0/confpub/front_matter.py +99 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/guide.py +64 -8
- confpub_cli-1.7.0/confpub/macro_plugin.py +152 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/publish.py +11 -3
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/reverse_converter.py +116 -4
- confpub_cli-1.7.0/confpub.lock +21 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_converter.py +30 -1
- confpub_cli-1.7.0/tests/test_front_matter.py +98 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_guide.py +1 -1
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_integration.py +102 -0
- confpub_cli-1.7.0/tests/test_macro_plugin.py +291 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_publish.py +20 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_reverse_converter.py +3 -3
- confpub_cli-1.5.0/confpub.lock +0 -10
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/.github/workflows/publish.yml +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/.gitignore +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/CLAUDE.md +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/LICENSE +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/PRD.md +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/applier.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/assets.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/config.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/confluence.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/envelope.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/errors.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/lockfile.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/manifest.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/output.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/planner.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/puller.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/py.typed +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/validator.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/verifier.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/pyproject.toml +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/__init__.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/conftest.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_applier.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_assets.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_config.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_confluence.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_envelope.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_errors.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_lockfile.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_manifest.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_output.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_planner.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_puller.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_validator.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_verifier.py +0 -0
- {confpub_cli-1.5.0 → confpub_cli-1.7.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: confpub-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.7.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, task lists, math, definition lists, footnotes, panels, expand/collapse, and
|
|
121
|
+
- **Markdown → Confluence** — code blocks become code macros, `> [!NOTE]` becomes Info panels, tables stay tables, task lists, math, definition lists, footnotes, panels, expand/collapse, page layouts, and `{macro}` syntax for Status, TOC, Jira, Anchor, Children, and more
|
|
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
|
|
@@ -353,6 +353,15 @@ confpub converts Markdown to Confluence Storage Format (and back via `page pull`
|
|
|
353
353
|
| `::: expand Title` | Confluence Expand macro |
|
|
354
354
|
| `:::: layout two-equal` | Confluence page layout |
|
|
355
355
|
| `---yaml---` front matter | Silently stripped |
|
|
356
|
+
| `{status:Done\|colour=Green}` | Confluence Status lozenge |
|
|
357
|
+
| `{toc}` | Table of Contents macro |
|
|
358
|
+
| `{anchor:name}` | Anchor macro |
|
|
359
|
+
| `{children}` | Children Display macro |
|
|
360
|
+
| `{jira:PROJECT-123}` | Jira issue link/table |
|
|
361
|
+
| `{recently-updated}` | Recently Updated macro |
|
|
362
|
+
| `{excerpt-include:Page}` | Excerpt Include macro |
|
|
363
|
+
| `{include:Page}` | Include Page macro |
|
|
364
|
+
| `::: excerpt` | Excerpt macro (body) |
|
|
356
365
|
|
|
357
366
|
---
|
|
358
367
|
|
|
@@ -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, task lists, math, definition lists, footnotes, panels, expand/collapse, and
|
|
80
|
+
- **Markdown → Confluence** — code blocks become code macros, `> [!NOTE]` becomes Info panels, tables stay tables, task lists, math, definition lists, footnotes, panels, expand/collapse, page layouts, and `{macro}` syntax for Status, TOC, Jira, Anchor, Children, and more
|
|
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
|
|
@@ -312,6 +312,15 @@ confpub converts Markdown to Confluence Storage Format (and back via `page pull`
|
|
|
312
312
|
| `::: expand Title` | Confluence Expand macro |
|
|
313
313
|
| `:::: layout two-equal` | Confluence page layout |
|
|
314
314
|
| `---yaml---` front matter | Silently stripped |
|
|
315
|
+
| `{status:Done\|colour=Green}` | Confluence Status lozenge |
|
|
316
|
+
| `{toc}` | Table of Contents macro |
|
|
317
|
+
| `{anchor:name}` | Anchor macro |
|
|
318
|
+
| `{children}` | Children Display macro |
|
|
319
|
+
| `{jira:PROJECT-123}` | Jira issue link/table |
|
|
320
|
+
| `{recently-updated}` | Recently Updated macro |
|
|
321
|
+
| `{excerpt-include:Page}` | Excerpt Include macro |
|
|
322
|
+
| `{include:Page}` | Include Page macro |
|
|
323
|
+
| `::: excerpt` | Excerpt macro (body) |
|
|
315
324
|
|
|
316
325
|
---
|
|
317
326
|
|
|
@@ -21,19 +21,19 @@ from confpub.errors import ConfpubError, exit_code_for, ERR_INTERNAL_SDK
|
|
|
21
21
|
from confpub.output import emit_stderr, emit_stdout, is_compact, is_verbose, set_compact, set_quiet, set_verbose
|
|
22
22
|
|
|
23
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."""
|
|
24
|
+
def _resolve_space(cli_space: str | None, required: bool = False, fm_space: str | None = None) -> str | None:
|
|
25
|
+
"""Resolve space from CLI flag, front-matter, or CONFPUB_SPACE env var, with validation."""
|
|
26
26
|
from confpub.config import ENV_SPACE
|
|
27
27
|
from confpub.errors import validate_space_key
|
|
28
28
|
|
|
29
|
-
space = cli_space or os.environ.get(ENV_SPACE)
|
|
29
|
+
space = cli_space or fm_space or os.environ.get(ENV_SPACE)
|
|
30
30
|
if space is not None:
|
|
31
31
|
validate_space_key(space)
|
|
32
32
|
return space
|
|
33
33
|
if required:
|
|
34
34
|
raise ConfpubError(
|
|
35
35
|
"ERR_VALIDATION_REQUIRED",
|
|
36
|
-
"Space key is required. Use --space or set CONFPUB_SPACE.",
|
|
36
|
+
"Space key is required. Use --space, front-matter, or set CONFPUB_SPACE.",
|
|
37
37
|
)
|
|
38
38
|
return None
|
|
39
39
|
|
|
@@ -290,30 +290,59 @@ def page_publish(
|
|
|
290
290
|
label: Optional[list[str]] = typer.Option(None, "--label", help="Label to apply (repeatable)"),
|
|
291
291
|
) -> None:
|
|
292
292
|
"""Publish a single Markdown file to Confluence."""
|
|
293
|
+
from pathlib import Path as _Path
|
|
294
|
+
from confpub.front_matter import parse_front_matter
|
|
293
295
|
from confpub.publish import derive_title
|
|
294
|
-
|
|
296
|
+
|
|
297
|
+
# Parse front-matter from the file (before command_context so title is resolved for target)
|
|
298
|
+
fm = None
|
|
299
|
+
source = _Path(file)
|
|
300
|
+
if source.exists():
|
|
301
|
+
md_text = source.read_text(encoding="utf-8")
|
|
302
|
+
fm = parse_front_matter(md_text)
|
|
303
|
+
|
|
304
|
+
fm_title = fm.title if fm else None
|
|
305
|
+
fm_space = fm.space if fm else None
|
|
306
|
+
fm_parent = fm.parent if fm else None
|
|
307
|
+
fm_page_id = fm.page_id if fm else None
|
|
308
|
+
fm_labels = fm.labels if fm else []
|
|
309
|
+
|
|
310
|
+
resolved_title = derive_title(file, title, title_from_h1=title_from_h1, front_matter_title=fm_title)
|
|
311
|
+
|
|
312
|
+
# Resolve page_id: CLI flag > front-matter
|
|
313
|
+
effective_page_id = page_id or fm_page_id
|
|
314
|
+
|
|
295
315
|
target = {"space": space, "title": resolved_title, "file": file}
|
|
296
|
-
if
|
|
297
|
-
target["page_id"] =
|
|
316
|
+
if effective_page_id:
|
|
317
|
+
target["page_id"] = effective_page_id
|
|
298
318
|
with command_context("page.publish", target=target) as ctx:
|
|
299
|
-
space = _resolve_space(space, required=True)
|
|
319
|
+
space = _resolve_space(space, required=True, fm_space=fm_space)
|
|
300
320
|
ctx.target["space"] = space
|
|
301
|
-
|
|
321
|
+
|
|
322
|
+
# Resolve parent: CLI flag > front-matter
|
|
323
|
+
effective_parent = parent or fm_parent
|
|
324
|
+
|
|
325
|
+
if not effective_page_id and not effective_parent:
|
|
302
326
|
raise ConfpubError(
|
|
303
327
|
"ERR_VALIDATION_REQUIRED",
|
|
304
|
-
"Either --page-id or --parent is required",
|
|
328
|
+
"Either --page-id or --parent is required (via flag or front-matter)",
|
|
305
329
|
)
|
|
330
|
+
|
|
331
|
+
# Merge labels: CLI + front-matter (deduplicated, order-preserving)
|
|
332
|
+
cli_labels = label or []
|
|
333
|
+
merged_labels = list(dict.fromkeys(cli_labels + fm_labels))
|
|
334
|
+
|
|
306
335
|
from confpub.publish import publish_page
|
|
307
336
|
result = publish_page(
|
|
308
337
|
file=file,
|
|
309
338
|
space=space,
|
|
310
|
-
parent=
|
|
311
|
-
title=
|
|
312
|
-
page_id=
|
|
339
|
+
parent=effective_parent or "",
|
|
340
|
+
title=resolved_title,
|
|
341
|
+
page_id=effective_page_id,
|
|
313
342
|
dry_run=dry_run,
|
|
314
343
|
backup=backup,
|
|
315
344
|
progress_callback=ctx,
|
|
316
|
-
labels=
|
|
345
|
+
labels=merged_labels,
|
|
317
346
|
)
|
|
318
347
|
ctx.result = result
|
|
319
348
|
|
|
@@ -13,6 +13,8 @@ import re
|
|
|
13
13
|
from html import escape
|
|
14
14
|
from typing import Any
|
|
15
15
|
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
16
18
|
from markdown_it import MarkdownIt
|
|
17
19
|
from markdown_it.token import Token
|
|
18
20
|
from mdit_py_plugins.tasklists import tasklists_plugin
|
|
@@ -22,6 +24,8 @@ from mdit_py_plugins.footnote import footnote_plugin
|
|
|
22
24
|
from mdit_py_plugins.front_matter import front_matter_plugin
|
|
23
25
|
from mdit_py_plugins.container import container_plugin
|
|
24
26
|
|
|
27
|
+
from confpub.macro_plugin import confluence_macro_plugin
|
|
28
|
+
|
|
25
29
|
# Admonition types mapping: GitHub [!TYPE] → Confluence macro name
|
|
26
30
|
ADMONITION_MAP: dict[str, str] = {
|
|
27
31
|
"NOTE": "info",
|
|
@@ -558,6 +562,136 @@ class ConfluenceRenderer:
|
|
|
558
562
|
self._output.append("</ac:layout-cell>")
|
|
559
563
|
return idx + 1
|
|
560
564
|
|
|
565
|
+
# ------------------------------------------------------------------
|
|
566
|
+
# Container: excerpt
|
|
567
|
+
# ------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
def _render_container_excerpt_open(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
|
|
570
|
+
info = tokens[idx].info.strip() if tokens[idx].info else ""
|
|
571
|
+
# info is e.g. "excerpt hidden" — strip the container name prefix
|
|
572
|
+
rest = info[len("excerpt"):].strip() if info.startswith("excerpt") else info
|
|
573
|
+
self._output.append('<ac:structured-macro ac:name="excerpt">')
|
|
574
|
+
if "hidden" in rest.lower():
|
|
575
|
+
self._output.append('<ac:parameter ac:name="hidden">true</ac:parameter>')
|
|
576
|
+
self._output.append("<ac:rich-text-body>")
|
|
577
|
+
return idx + 1
|
|
578
|
+
|
|
579
|
+
def _render_container_excerpt_close(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
|
|
580
|
+
self._output.append("</ac:rich-text-body></ac:structured-macro>")
|
|
581
|
+
return idx + 1
|
|
582
|
+
|
|
583
|
+
# ------------------------------------------------------------------
|
|
584
|
+
# Confluence macros (body-less, from macro_plugin.py)
|
|
585
|
+
# ------------------------------------------------------------------
|
|
586
|
+
|
|
587
|
+
def _render_confluence_macro(self, tokens: list[Token], idx: int, _o: Any, _e: Any) -> int:
|
|
588
|
+
"""Block-level macro token."""
|
|
589
|
+
token = tokens[idx]
|
|
590
|
+
name = token.info
|
|
591
|
+
meta = token.meta or {}
|
|
592
|
+
self._output.append(self._emit_macro(name, meta.get("positional", ""), meta.get("params", {})))
|
|
593
|
+
return idx + 1
|
|
594
|
+
|
|
595
|
+
def _inline_confluence_macro(self, token: Token) -> None:
|
|
596
|
+
"""Inline macro token."""
|
|
597
|
+
name = token.info
|
|
598
|
+
meta = token.meta or {}
|
|
599
|
+
self._output.append(self._emit_macro(name, meta.get("positional", ""), meta.get("params", {})))
|
|
600
|
+
|
|
601
|
+
def _emit_macro(self, name: str, positional: str, params: dict[str, str]) -> str:
|
|
602
|
+
"""Dispatch to per-macro handler or fall back to generic."""
|
|
603
|
+
method_name = f"_macro_{name.replace('-', '_')}"
|
|
604
|
+
handler = getattr(self, method_name, None)
|
|
605
|
+
if handler:
|
|
606
|
+
return handler(positional, params)
|
|
607
|
+
return self._emit_generic_macro(name, positional, params)
|
|
608
|
+
|
|
609
|
+
def _emit_generic_macro(self, name: str, positional: str, params: dict[str, str]) -> str:
|
|
610
|
+
parts = [f'<ac:structured-macro ac:name="{escape(name)}">']
|
|
611
|
+
if positional:
|
|
612
|
+
parts.append(f'<ac:parameter ac:name="">{escape(positional)}</ac:parameter>')
|
|
613
|
+
for k, v in params.items():
|
|
614
|
+
parts.append(f'<ac:parameter ac:name="{escape(k)}">{escape(v)}</ac:parameter>')
|
|
615
|
+
parts.append("</ac:structured-macro>")
|
|
616
|
+
return "".join(parts)
|
|
617
|
+
|
|
618
|
+
def _macro_status(self, positional: str, params: dict[str, str]) -> str:
|
|
619
|
+
parts = ['<ac:structured-macro ac:name="status">']
|
|
620
|
+
if positional:
|
|
621
|
+
parts.append(f'<ac:parameter ac:name="title">{escape(positional)}</ac:parameter>')
|
|
622
|
+
for k, v in params.items():
|
|
623
|
+
parts.append(f'<ac:parameter ac:name="{escape(k)}">{escape(v)}</ac:parameter>')
|
|
624
|
+
parts.append("</ac:structured-macro>")
|
|
625
|
+
return "".join(parts)
|
|
626
|
+
|
|
627
|
+
def _macro_toc(self, positional: str, params: dict[str, str]) -> str:
|
|
628
|
+
parts = ['<ac:structured-macro ac:name="toc">']
|
|
629
|
+
for k, v in params.items():
|
|
630
|
+
parts.append(f'<ac:parameter ac:name="{escape(k)}">{escape(v)}</ac:parameter>')
|
|
631
|
+
parts.append("</ac:structured-macro>")
|
|
632
|
+
return "".join(parts)
|
|
633
|
+
|
|
634
|
+
def _macro_anchor(self, positional: str, params: dict[str, str]) -> str:
|
|
635
|
+
parts = ['<ac:structured-macro ac:name="anchor">']
|
|
636
|
+
if positional:
|
|
637
|
+
parts.append(f'<ac:parameter ac:name="">{escape(positional)}</ac:parameter>')
|
|
638
|
+
parts.append("</ac:structured-macro>")
|
|
639
|
+
return "".join(parts)
|
|
640
|
+
|
|
641
|
+
def _macro_children(self, positional: str, params: dict[str, str]) -> str:
|
|
642
|
+
parts = ['<ac:structured-macro ac:name="children">']
|
|
643
|
+
for k, v in params.items():
|
|
644
|
+
parts.append(f'<ac:parameter ac:name="{escape(k)}">{escape(v)}</ac:parameter>')
|
|
645
|
+
parts.append("</ac:structured-macro>")
|
|
646
|
+
return "".join(parts)
|
|
647
|
+
|
|
648
|
+
def _macro_jira(self, positional: str, params: dict[str, str]) -> str:
|
|
649
|
+
parts = ['<ac:structured-macro ac:name="jira">']
|
|
650
|
+
if "jql" in params or "jqlQuery" in params:
|
|
651
|
+
jql_value = params.pop("jql", "") or params.pop("jqlQuery", "")
|
|
652
|
+
parts.append(f'<ac:parameter ac:name="jqlQuery">{escape(jql_value)}</ac:parameter>')
|
|
653
|
+
elif positional:
|
|
654
|
+
parts.append(f'<ac:parameter ac:name="key">{escape(positional)}</ac:parameter>')
|
|
655
|
+
for k, v in params.items():
|
|
656
|
+
parts.append(f'<ac:parameter ac:name="{escape(k)}">{escape(v)}</ac:parameter>')
|
|
657
|
+
parts.append("</ac:structured-macro>")
|
|
658
|
+
return "".join(parts)
|
|
659
|
+
|
|
660
|
+
def _macro_recently_updated(self, positional: str, params: dict[str, str]) -> str:
|
|
661
|
+
parts = ['<ac:structured-macro ac:name="recently-updated">']
|
|
662
|
+
for k, v in params.items():
|
|
663
|
+
parts.append(f'<ac:parameter ac:name="{escape(k)}">{escape(v)}</ac:parameter>')
|
|
664
|
+
parts.append("</ac:structured-macro>")
|
|
665
|
+
return "".join(parts)
|
|
666
|
+
|
|
667
|
+
def _macro_excerpt_include(self, positional: str, params: dict[str, str]) -> str:
|
|
668
|
+
space = params.pop("space", "")
|
|
669
|
+
parts = ['<ac:structured-macro ac:name="excerpt-include">']
|
|
670
|
+
parts.append('<ac:parameter ac:name="">')
|
|
671
|
+
ri_attrs = f'ri:content-title="{escape(positional)}"'
|
|
672
|
+
if space:
|
|
673
|
+
ri_attrs += f' ri:space-key="{escape(space)}"'
|
|
674
|
+
parts.append(f"<ac:link><ri:page {ri_attrs} /></ac:link>")
|
|
675
|
+
parts.append("</ac:parameter>")
|
|
676
|
+
for k, v in params.items():
|
|
677
|
+
parts.append(f'<ac:parameter ac:name="{escape(k)}">{escape(v)}</ac:parameter>')
|
|
678
|
+
parts.append("</ac:structured-macro>")
|
|
679
|
+
return "".join(parts)
|
|
680
|
+
|
|
681
|
+
def _macro_include(self, positional: str, params: dict[str, str]) -> str:
|
|
682
|
+
space = params.pop("space", "")
|
|
683
|
+
parts = ['<ac:structured-macro ac:name="include">']
|
|
684
|
+
parts.append('<ac:parameter ac:name="">')
|
|
685
|
+
ri_attrs = f'ri:content-title="{escape(positional)}"'
|
|
686
|
+
if space:
|
|
687
|
+
ri_attrs += f' ri:space-key="{escape(space)}"'
|
|
688
|
+
parts.append(f"<ac:link><ri:page {ri_attrs} /></ac:link>")
|
|
689
|
+
parts.append("</ac:parameter>")
|
|
690
|
+
for k, v in params.items():
|
|
691
|
+
parts.append(f'<ac:parameter ac:name="{escape(k)}">{escape(v)}</ac:parameter>')
|
|
692
|
+
parts.append("</ac:structured-macro>")
|
|
693
|
+
return "".join(parts)
|
|
694
|
+
|
|
561
695
|
|
|
562
696
|
def _create_parser() -> MarkdownIt:
|
|
563
697
|
"""Create a configured markdown-it-py parser."""
|
|
@@ -574,6 +708,8 @@ def _create_parser() -> MarkdownIt:
|
|
|
574
708
|
container_plugin(md, name="expand")
|
|
575
709
|
container_plugin(md, name="layout")
|
|
576
710
|
container_plugin(md, name="cell")
|
|
711
|
+
container_plugin(md, name="excerpt")
|
|
712
|
+
confluence_macro_plugin(md)
|
|
577
713
|
return md
|
|
578
714
|
|
|
579
715
|
|
|
@@ -613,6 +749,27 @@ def extract_h1_title(md_text: str) -> str | None:
|
|
|
613
749
|
return None
|
|
614
750
|
|
|
615
751
|
|
|
752
|
+
def extract_front_matter(md_text: str) -> dict[str, Any] | None:
|
|
753
|
+
"""Extract YAML front-matter as a raw dict, or None.
|
|
754
|
+
|
|
755
|
+
Uses the markdown-it-py front_matter plugin to locate the block,
|
|
756
|
+
then parses with yaml.safe_load. Returns None if no front-matter
|
|
757
|
+
is present, if the YAML is invalid, or if the result is not a mapping.
|
|
758
|
+
"""
|
|
759
|
+
parser = _create_parser()
|
|
760
|
+
tokens = parser.parse(md_text)
|
|
761
|
+
for token in tokens:
|
|
762
|
+
if token.type == "front_matter":
|
|
763
|
+
try:
|
|
764
|
+
data = yaml.safe_load(token.content)
|
|
765
|
+
except yaml.YAMLError:
|
|
766
|
+
return None
|
|
767
|
+
if isinstance(data, dict):
|
|
768
|
+
return data
|
|
769
|
+
return None
|
|
770
|
+
return None
|
|
771
|
+
|
|
772
|
+
|
|
616
773
|
def fingerprint_content(content: str) -> str:
|
|
617
774
|
"""Return SHA-256 hex digest of content."""
|
|
618
775
|
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Front-matter extraction and validation for single-file publish.
|
|
2
|
+
|
|
3
|
+
Parses YAML front-matter from Markdown files into a typed dataclass.
|
|
4
|
+
Used only by `page publish`; manifest flows ignore front-matter entirely.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from confpub.converter import extract_front_matter
|
|
14
|
+
from confpub.errors import ERR_VALIDATION_MARKDOWN, ConfpubError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class FrontMatterData:
|
|
19
|
+
"""Validated front-matter fields."""
|
|
20
|
+
|
|
21
|
+
title: str | None = None
|
|
22
|
+
space: str | None = None
|
|
23
|
+
parent: str | None = None
|
|
24
|
+
labels: list[str] = field(default_factory=list)
|
|
25
|
+
page_id: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _validate_string(raw: dict[str, Any], key: str) -> str | None:
|
|
29
|
+
"""Extract and validate a string field, or None if absent."""
|
|
30
|
+
val = raw.get(key)
|
|
31
|
+
if val is None:
|
|
32
|
+
return None
|
|
33
|
+
if not isinstance(val, str):
|
|
34
|
+
raise ConfpubError(
|
|
35
|
+
ERR_VALIDATION_MARKDOWN,
|
|
36
|
+
f"Front-matter field '{key}' must be a string, got {type(val).__name__}",
|
|
37
|
+
details={"field": key, "value_type": type(val).__name__},
|
|
38
|
+
)
|
|
39
|
+
return val
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_front_matter(md_text: str) -> FrontMatterData | None:
|
|
43
|
+
"""Extract and validate front-matter from Markdown text.
|
|
44
|
+
|
|
45
|
+
Returns a FrontMatterData with validated fields, or None if no
|
|
46
|
+
front-matter is present. Unknown keys are silently ignored.
|
|
47
|
+
|
|
48
|
+
Raises ConfpubError (ERR_VALIDATION_MARKDOWN) on type errors.
|
|
49
|
+
"""
|
|
50
|
+
raw = extract_front_matter(md_text)
|
|
51
|
+
if raw is None:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
title = _validate_string(raw, "title")
|
|
55
|
+
space = _validate_string(raw, "space")
|
|
56
|
+
parent = _validate_string(raw, "parent")
|
|
57
|
+
|
|
58
|
+
# labels: list[str] or single string → list[str]
|
|
59
|
+
labels_raw = raw.get("labels")
|
|
60
|
+
labels: list[str] = []
|
|
61
|
+
if labels_raw is not None:
|
|
62
|
+
if isinstance(labels_raw, str):
|
|
63
|
+
labels = [labels_raw]
|
|
64
|
+
elif isinstance(labels_raw, list):
|
|
65
|
+
for item in labels_raw:
|
|
66
|
+
if not isinstance(item, str):
|
|
67
|
+
raise ConfpubError(
|
|
68
|
+
ERR_VALIDATION_MARKDOWN,
|
|
69
|
+
f"Front-matter 'labels' items must be strings, got {type(item).__name__}",
|
|
70
|
+
details={"field": "labels", "value_type": type(item).__name__},
|
|
71
|
+
)
|
|
72
|
+
labels = labels_raw
|
|
73
|
+
else:
|
|
74
|
+
raise ConfpubError(
|
|
75
|
+
ERR_VALIDATION_MARKDOWN,
|
|
76
|
+
f"Front-matter 'labels' must be a list or string, got {type(labels_raw).__name__}",
|
|
77
|
+
details={"field": "labels", "value_type": type(labels_raw).__name__},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# page_id: string or int → string
|
|
81
|
+
page_id_raw = raw.get("page_id")
|
|
82
|
+
page_id: str | None = None
|
|
83
|
+
if page_id_raw is not None:
|
|
84
|
+
if isinstance(page_id_raw, (str, int)):
|
|
85
|
+
page_id = str(page_id_raw)
|
|
86
|
+
else:
|
|
87
|
+
raise ConfpubError(
|
|
88
|
+
ERR_VALIDATION_MARKDOWN,
|
|
89
|
+
f"Front-matter 'page_id' must be a string or integer, got {type(page_id_raw).__name__}",
|
|
90
|
+
details={"field": "page_id", "value_type": type(page_id_raw).__name__},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return FrontMatterData(
|
|
94
|
+
title=title,
|
|
95
|
+
space=space,
|
|
96
|
+
parent=parent,
|
|
97
|
+
labels=labels,
|
|
98
|
+
page_id=page_id,
|
|
99
|
+
)
|
|
@@ -138,12 +138,12 @@ def build_guide() -> dict[str, Any]:
|
|
|
138
138
|
"description": "Publish a single Markdown file to Confluence",
|
|
139
139
|
"flags": ["--space", "--parent", "--title", "--title-from-h1", "--page-id", "--dry-run", "--backup", "--label"],
|
|
140
140
|
"agent_hint": (
|
|
141
|
-
"Title precedence: explicit --title > --title-from-h1
|
|
142
|
-
"
|
|
143
|
-
"
|
|
144
|
-
"
|
|
145
|
-
"
|
|
146
|
-
"
|
|
141
|
+
"Title precedence: explicit --title > --title-from-h1 > front-matter title > filename inference. "
|
|
142
|
+
"Space precedence: --space > front-matter space > CONFPUB_SPACE env var. "
|
|
143
|
+
"Parent precedence: --parent > front-matter parent. "
|
|
144
|
+
"Labels: CLI --label merged with front-matter labels (union, deduplicated). "
|
|
145
|
+
"When writing Markdown files for publication, include YAML front-matter to embed metadata: "
|
|
146
|
+
"---\\ntitle: Page Title\\nspace: SPACEKEY\\nparent: Parent Title\\nlabels:\\n - tag1\\n---\\n "
|
|
147
147
|
"For personal spaces, quote the tilde: --space '~username' "
|
|
148
148
|
"(PowerShell expands unquoted ~). Or set CONFPUB_SPACE env var."
|
|
149
149
|
),
|
|
@@ -402,16 +402,72 @@ def build_guide() -> dict[str, Any]:
|
|
|
402
402
|
"math_block": "$$...$$ → ac:structured-macro mathblock",
|
|
403
403
|
"definition_lists": "Term\\n: Definition → <dl><dt><dd>",
|
|
404
404
|
"footnotes": "[^1] + [^1]: text → superscript links with numbered list",
|
|
405
|
-
"front_matter":
|
|
405
|
+
"front_matter": (
|
|
406
|
+
"---\\nyaml\\n--- → extracted for page metadata "
|
|
407
|
+
"(title, space, parent, labels, page_id); "
|
|
408
|
+
"used by page.publish; ignored when a manifest is used"
|
|
409
|
+
),
|
|
406
410
|
"panels": "::: panel Title\\ncontent\\n::: → ac:structured-macro panel",
|
|
407
411
|
"expand": "::: expand Title\\ncontent\\n::: → ac:structured-macro expand",
|
|
408
412
|
"layouts": ":::: layout two-equal\\n::: cell\\n...\\n::::\\n → ac:layout with ac:layout-section",
|
|
413
|
+
"status": "{status:Title|colour=Color} → ac:structured-macro status",
|
|
414
|
+
"toc": "{toc} / {toc:maxLevel=N} → ac:structured-macro toc",
|
|
415
|
+
"anchor": "{anchor:name} → ac:structured-macro anchor",
|
|
416
|
+
"children": "{children} / {children:depth=N} → ac:structured-macro children",
|
|
417
|
+
"jira": "{jira:KEY-123} / {jira:jql=...} → ac:structured-macro jira",
|
|
418
|
+
"recently_updated": "{recently-updated} → ac:structured-macro recently-updated",
|
|
419
|
+
"excerpt_include": "{excerpt-include:Page Title} → ac:structured-macro excerpt-include",
|
|
420
|
+
"include_page": "{include:Page Title} → ac:structured-macro include",
|
|
421
|
+
"excerpt": "::: excerpt hidden\\ncontent\\n::: → ac:structured-macro excerpt",
|
|
409
422
|
},
|
|
410
423
|
"layout_types": ["single", "two-equal", "two-left-sidebar", "two-right-sidebar", "three-equal", "three-with-sidebars"],
|
|
411
424
|
"agent_hint": (
|
|
412
425
|
"All features are always-on — the parser simply ignores syntax that isn't used. "
|
|
413
426
|
"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."
|
|
427
|
+
"Layouts use :::: (4 colons) for the outer layout block and ::: (3 colons) for inner cells. "
|
|
428
|
+
"Use {macro-name:params} for body-less Confluence macros. "
|
|
429
|
+
"Macros on their own line become block-level (no <p> wrapping)."
|
|
430
|
+
),
|
|
431
|
+
},
|
|
432
|
+
"front_matter": {
|
|
433
|
+
"description": (
|
|
434
|
+
"YAML front-matter in Markdown files provides default page metadata for page.publish. "
|
|
435
|
+
"When a manifest (confpub.yaml) is used, front-matter is ignored entirely."
|
|
436
|
+
),
|
|
437
|
+
"fields": {
|
|
438
|
+
"title": "Page title (string)",
|
|
439
|
+
"space": "Confluence space key (string)",
|
|
440
|
+
"parent": "Parent page title (string)",
|
|
441
|
+
"labels": "Labels to apply (list of strings, or single string)",
|
|
442
|
+
"page_id": "Confluence page ID for direct update (string or integer)",
|
|
443
|
+
},
|
|
444
|
+
"precedence": {
|
|
445
|
+
"title": "--title > --title-from-h1 > front-matter > filename",
|
|
446
|
+
"space": "--space > front-matter > CONFPUB_SPACE",
|
|
447
|
+
"parent": "--parent > front-matter",
|
|
448
|
+
"page_id": "--page-id > front-matter",
|
|
449
|
+
"labels": "CLI --label + front-matter labels merged (deduplicated)",
|
|
450
|
+
},
|
|
451
|
+
"example": (
|
|
452
|
+
"---\n"
|
|
453
|
+
"title: API Reference\n"
|
|
454
|
+
"space: DEV\n"
|
|
455
|
+
"parent: Documentation\n"
|
|
456
|
+
"labels:\n"
|
|
457
|
+
" - api\n"
|
|
458
|
+
" - public\n"
|
|
459
|
+
"---\n"
|
|
460
|
+
"\n"
|
|
461
|
+
"# API Reference\n"
|
|
462
|
+
"\n"
|
|
463
|
+
"Content here..."
|
|
464
|
+
),
|
|
465
|
+
"agent_hint": (
|
|
466
|
+
"When creating Markdown files for Confluence publication, always include "
|
|
467
|
+
"front-matter with at least title, space, and parent so the file can be "
|
|
468
|
+
"published with just `confpub page publish <file>` — no extra flags needed. "
|
|
469
|
+
"Unknown front-matter keys (e.g. draft, author) are silently ignored, "
|
|
470
|
+
"so front-matter is compatible with other tools like Jekyll or Hugo."
|
|
415
471
|
),
|
|
416
472
|
},
|
|
417
473
|
"assertions": {
|