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.
Files changed (55) hide show
  1. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/PKG-INFO +11 -2
  2. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/README.md +10 -1
  3. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/__init__.py +1 -1
  4. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/cli.py +43 -14
  5. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/converter.py +157 -0
  6. confpub_cli-1.7.0/confpub/front_matter.py +99 -0
  7. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/guide.py +64 -8
  8. confpub_cli-1.7.0/confpub/macro_plugin.py +152 -0
  9. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/publish.py +11 -3
  10. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/reverse_converter.py +116 -4
  11. confpub_cli-1.7.0/confpub.lock +21 -0
  12. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_converter.py +30 -1
  13. confpub_cli-1.7.0/tests/test_front_matter.py +98 -0
  14. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_guide.py +1 -1
  15. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_integration.py +102 -0
  16. confpub_cli-1.7.0/tests/test_macro_plugin.py +291 -0
  17. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_publish.py +20 -0
  18. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_reverse_converter.py +3 -3
  19. confpub_cli-1.5.0/confpub.lock +0 -10
  20. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/.github/workflows/publish.yml +0 -0
  21. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/.gitignore +0 -0
  22. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/CLAUDE.md +0 -0
  23. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/LICENSE +0 -0
  24. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/PRD.md +0 -0
  25. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/applier.py +0 -0
  26. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/assets.py +0 -0
  27. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/config.py +0 -0
  28. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/confluence.py +0 -0
  29. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/envelope.py +0 -0
  30. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/errors.py +0 -0
  31. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/lockfile.py +0 -0
  32. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/manifest.py +0 -0
  33. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/output.py +0 -0
  34. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/planner.py +0 -0
  35. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/puller.py +0 -0
  36. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/py.typed +0 -0
  37. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/validator.py +0 -0
  38. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/confpub/verifier.py +0 -0
  39. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/pyproject.toml +0 -0
  40. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/__init__.py +0 -0
  41. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/conftest.py +0 -0
  42. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_applier.py +0 -0
  43. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_assets.py +0 -0
  44. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_config.py +0 -0
  45. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_confluence.py +0 -0
  46. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_envelope.py +0 -0
  47. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_errors.py +0 -0
  48. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_lockfile.py +0 -0
  49. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_manifest.py +0 -0
  50. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_output.py +0 -0
  51. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_planner.py +0 -0
  52. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_puller.py +0 -0
  53. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_validator.py +0 -0
  54. {confpub_cli-1.5.0 → confpub_cli-1.7.0}/tests/test_verifier.py +0 -0
  55. {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.5.0
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 page layouts
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 page layouts
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
 
@@ -1,3 +1,3 @@
1
1
  """confpub — Agent-first CLI to publish Markdown to Confluence."""
2
2
 
3
- __version__ = "1.5.0"
3
+ __version__ = "1.7.0"
@@ -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
- resolved_title = derive_title(file, title, title_from_h1=title_from_h1)
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 page_id:
297
- target["page_id"] = 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
- if not page_id and not parent:
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=parent or "",
311
- title=title,
312
- page_id=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=label or [],
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 (first H1 heading) > filename inference. "
142
- "When --title is omitted, the title is inferred from the filename: "
143
- "the stem is extracted, hyphens and underscores are replaced with spaces, "
144
- "and the result is title-cased. E.g. 'my-cool-page.md' 'My Cool Page'. "
145
- "Use --title-from-h1 to extract the title from the first # heading in the file. "
146
- "Use --label to apply labels (repeatable): --label api --label docs. "
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": "---\\nyaml\\n--- → silently stripped",
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": {