confpub-cli 1.6.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.6.0 → confpub_cli-1.7.0}/PKG-INFO +1 -1
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/__init__.py +1 -1
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/cli.py +43 -14
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/converter.py +23 -0
- confpub_cli-1.7.0/confpub/front_matter.py +99 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/guide.py +52 -7
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/publish.py +11 -3
- {confpub_cli-1.6.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.6.0 → confpub_cli-1.7.0}/tests/test_guide.py +1 -1
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_integration.py +102 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_publish.py +20 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/.github/workflows/publish.yml +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/.gitignore +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/CLAUDE.md +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/LICENSE +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/PRD.md +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/README.md +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/applier.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/assets.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/config.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/confluence.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/envelope.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/errors.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/lockfile.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/macro_plugin.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/manifest.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/output.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/planner.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/puller.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/py.typed +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/reverse_converter.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/validator.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/verifier.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub.lock +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/pyproject.toml +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/__init__.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/conftest.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_applier.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_assets.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_config.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_confluence.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_envelope.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_errors.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_lockfile.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_macro_plugin.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_manifest.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_output.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_planner.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_puller.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_reverse_converter.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_validator.py +0 -0
- {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_verifier.py +0 -0
- {confpub_cli-1.6.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
|
|
@@ -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
|
|
@@ -747,6 +749,27 @@ def extract_h1_title(md_text: str) -> str | None:
|
|
|
747
749
|
return None
|
|
748
750
|
|
|
749
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
|
+
|
|
750
773
|
def fingerprint_content(content: str) -> str:
|
|
751
774
|
"""Return SHA-256 hex digest of content."""
|
|
752
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,7 +402,11 @@ 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",
|
|
@@ -425,6 +429,47 @@ def build_guide() -> dict[str, Any]:
|
|
|
425
429
|
"Macros on their own line become block-level (no <p> wrapping)."
|
|
426
430
|
),
|
|
427
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."
|
|
471
|
+
),
|
|
472
|
+
},
|
|
428
473
|
"assertions": {
|
|
429
474
|
"description": "Post-condition assertions verified by plan.verify.",
|
|
430
475
|
"file_format": "JSON array of assertion objects, or embedded in confpub.yaml under the 'assertions' key.",
|
|
@@ -23,10 +23,16 @@ from confpub.errors import (
|
|
|
23
23
|
from confpub.lockfile import Lockfile, load_lockfile, save_lockfile, update_lockfile
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def derive_title(
|
|
27
|
-
|
|
26
|
+
def derive_title(
|
|
27
|
+
file: str,
|
|
28
|
+
title: str | None = None,
|
|
29
|
+
*,
|
|
30
|
+
title_from_h1: bool = False,
|
|
31
|
+
front_matter_title: str | None = None,
|
|
32
|
+
) -> str:
|
|
33
|
+
"""Derive page title from explicit title, H1 heading, front-matter, or filename.
|
|
28
34
|
|
|
29
|
-
Precedence: explicit --title > --title-from-h1 > filename inference.
|
|
35
|
+
Precedence: explicit --title > --title-from-h1 > front-matter > filename inference.
|
|
30
36
|
"""
|
|
31
37
|
if title:
|
|
32
38
|
return title
|
|
@@ -36,6 +42,8 @@ def derive_title(file: str, title: str | None = None, *, title_from_h1: bool = F
|
|
|
36
42
|
h1 = extract_h1_title(md_text)
|
|
37
43
|
if h1:
|
|
38
44
|
return h1
|
|
45
|
+
if front_matter_title:
|
|
46
|
+
return front_matter_title
|
|
39
47
|
return Path(file).stem.replace("-", " ").replace("_", " ").title()
|
|
40
48
|
|
|
41
49
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Tests for confpub.converter module."""
|
|
2
2
|
|
|
3
|
-
from confpub.converter import convert_markdown, extract_h1_title, fingerprint_content
|
|
3
|
+
from confpub.converter import convert_markdown, extract_front_matter, extract_h1_title, fingerprint_content
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
class TestHeadings:
|
|
@@ -419,3 +419,32 @@ class TestContainerLayout:
|
|
|
419
419
|
result = convert_markdown(md)
|
|
420
420
|
assert 'ac:type="three_equal"' in result
|
|
421
421
|
assert result.count("<ac:layout-cell>") == 3
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class TestExtractFrontMatter:
|
|
425
|
+
def test_basic_extraction(self):
|
|
426
|
+
md = "---\ntitle: Hello\nspace: DEV\n---\n\n# Content"
|
|
427
|
+
result = extract_front_matter(md)
|
|
428
|
+
assert result is not None
|
|
429
|
+
assert result["title"] == "Hello"
|
|
430
|
+
assert result["space"] == "DEV"
|
|
431
|
+
|
|
432
|
+
def test_no_front_matter_returns_none(self):
|
|
433
|
+
md = "# Just a heading\n\nParagraph."
|
|
434
|
+
assert extract_front_matter(md) is None
|
|
435
|
+
|
|
436
|
+
def test_invalid_yaml_returns_none(self):
|
|
437
|
+
md = "---\n: invalid: yaml: {{{\n---\n\nContent"
|
|
438
|
+
assert extract_front_matter(md) is None
|
|
439
|
+
|
|
440
|
+
def test_non_mapping_returns_none(self):
|
|
441
|
+
md = "---\n- just\n- a\n- list\n---\n\nContent"
|
|
442
|
+
assert extract_front_matter(md) is None
|
|
443
|
+
|
|
444
|
+
def test_preserves_unknown_keys(self):
|
|
445
|
+
md = "---\ntitle: Page\nauthor: Alice\ndraft: true\n---\n\nContent"
|
|
446
|
+
result = extract_front_matter(md)
|
|
447
|
+
assert result is not None
|
|
448
|
+
assert result["title"] == "Page"
|
|
449
|
+
assert result["author"] == "Alice"
|
|
450
|
+
assert result["draft"] is True
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Tests for confpub.front_matter module."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from confpub.errors import ConfpubError, ERR_VALIDATION_MARKDOWN
|
|
6
|
+
from confpub.front_matter import FrontMatterData, parse_front_matter
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestParseFrontMatter:
|
|
10
|
+
def test_all_fields_populated(self):
|
|
11
|
+
md = (
|
|
12
|
+
"---\n"
|
|
13
|
+
"title: My Page\n"
|
|
14
|
+
"space: DEV\n"
|
|
15
|
+
"parent: Documentation\n"
|
|
16
|
+
"labels:\n"
|
|
17
|
+
" - api\n"
|
|
18
|
+
" - public\n"
|
|
19
|
+
"page_id: '123456'\n"
|
|
20
|
+
"---\n"
|
|
21
|
+
"\n"
|
|
22
|
+
"# Content\n"
|
|
23
|
+
)
|
|
24
|
+
fm = parse_front_matter(md)
|
|
25
|
+
assert fm is not None
|
|
26
|
+
assert fm.title == "My Page"
|
|
27
|
+
assert fm.space == "DEV"
|
|
28
|
+
assert fm.parent == "Documentation"
|
|
29
|
+
assert fm.labels == ["api", "public"]
|
|
30
|
+
assert fm.page_id == "123456"
|
|
31
|
+
|
|
32
|
+
def test_partial_fields(self):
|
|
33
|
+
md = "---\ntitle: Just Title\n---\n\nContent"
|
|
34
|
+
fm = parse_front_matter(md)
|
|
35
|
+
assert fm is not None
|
|
36
|
+
assert fm.title == "Just Title"
|
|
37
|
+
assert fm.space is None
|
|
38
|
+
assert fm.parent is None
|
|
39
|
+
assert fm.labels == []
|
|
40
|
+
assert fm.page_id is None
|
|
41
|
+
|
|
42
|
+
def test_no_front_matter_returns_none(self):
|
|
43
|
+
md = "# No front matter\n\nJust content."
|
|
44
|
+
assert parse_front_matter(md) is None
|
|
45
|
+
|
|
46
|
+
def test_single_string_labels_coercion(self):
|
|
47
|
+
md = "---\nlabels: api\n---\n\nContent"
|
|
48
|
+
fm = parse_front_matter(md)
|
|
49
|
+
assert fm is not None
|
|
50
|
+
assert fm.labels == ["api"]
|
|
51
|
+
|
|
52
|
+
def test_int_page_id_coercion(self):
|
|
53
|
+
md = "---\npage_id: 789\n---\n\nContent"
|
|
54
|
+
fm = parse_front_matter(md)
|
|
55
|
+
assert fm is not None
|
|
56
|
+
assert fm.page_id == "789"
|
|
57
|
+
|
|
58
|
+
def test_unknown_keys_ignored(self):
|
|
59
|
+
md = "---\ntitle: Page\nauthor: Alice\ndraft: true\n---\n\nContent"
|
|
60
|
+
fm = parse_front_matter(md)
|
|
61
|
+
assert fm is not None
|
|
62
|
+
assert fm.title == "Page"
|
|
63
|
+
# No error from unknown keys
|
|
64
|
+
|
|
65
|
+
def test_title_as_list_raises(self):
|
|
66
|
+
md = "---\ntitle:\n - one\n - two\n---\n\nContent"
|
|
67
|
+
with pytest.raises(ConfpubError) as exc_info:
|
|
68
|
+
parse_front_matter(md)
|
|
69
|
+
assert exc_info.value.code == ERR_VALIDATION_MARKDOWN
|
|
70
|
+
assert "title" in exc_info.value.error_message
|
|
71
|
+
|
|
72
|
+
def test_labels_as_int_raises(self):
|
|
73
|
+
md = "---\nlabels: 42\n---\n\nContent"
|
|
74
|
+
with pytest.raises(ConfpubError) as exc_info:
|
|
75
|
+
parse_front_matter(md)
|
|
76
|
+
assert exc_info.value.code == ERR_VALIDATION_MARKDOWN
|
|
77
|
+
assert "labels" in exc_info.value.error_message
|
|
78
|
+
|
|
79
|
+
def test_labels_with_non_string_item_raises(self):
|
|
80
|
+
md = "---\nlabels:\n - good\n - 123\n---\n\nContent"
|
|
81
|
+
with pytest.raises(ConfpubError) as exc_info:
|
|
82
|
+
parse_front_matter(md)
|
|
83
|
+
assert exc_info.value.code == ERR_VALIDATION_MARKDOWN
|
|
84
|
+
assert "labels" in exc_info.value.error_message
|
|
85
|
+
|
|
86
|
+
def test_page_id_as_list_raises(self):
|
|
87
|
+
md = "---\npage_id:\n - 1\n - 2\n---\n\nContent"
|
|
88
|
+
with pytest.raises(ConfpubError) as exc_info:
|
|
89
|
+
parse_front_matter(md)
|
|
90
|
+
assert exc_info.value.code == ERR_VALIDATION_MARKDOWN
|
|
91
|
+
assert "page_id" in exc_info.value.error_message
|
|
92
|
+
|
|
93
|
+
def test_space_as_int_raises(self):
|
|
94
|
+
md = "---\nspace: 42\n---\n\nContent"
|
|
95
|
+
with pytest.raises(ConfpubError) as exc_info:
|
|
96
|
+
parse_front_matter(md)
|
|
97
|
+
assert exc_info.value.code == ERR_VALIDATION_MARKDOWN
|
|
98
|
+
assert "space" in exc_info.value.error_message
|
|
@@ -137,7 +137,7 @@ class TestBuildGuide:
|
|
|
137
137
|
guide = build_guide()
|
|
138
138
|
cmd = guide["commands"]["page.publish"]
|
|
139
139
|
assert "agent_hint" in cmd
|
|
140
|
-
assert "
|
|
140
|
+
assert "filename inference" in cmd["agent_hint"]
|
|
141
141
|
|
|
142
142
|
def test_page_pull_has_progress_hint(self):
|
|
143
143
|
guide = build_guide()
|
|
@@ -551,3 +551,105 @@ class TestEnvelopeContract:
|
|
|
551
551
|
assert isinstance(data["metrics"], dict)
|
|
552
552
|
assert "duration_ms" in data["metrics"]
|
|
553
553
|
assert data["request_id"].startswith("req_")
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
class TestPagePublishFrontMatter:
|
|
557
|
+
"""Front-matter provides defaults for page publish."""
|
|
558
|
+
|
|
559
|
+
def test_front_matter_provides_space_and_parent(self, tmp_path, monkeypatch):
|
|
560
|
+
"""Front-matter space/parent used when no CLI flags given."""
|
|
561
|
+
md = "---\ntitle: FM Page\nspace: DEV\nparent: Docs\n---\n\n# Content"
|
|
562
|
+
md_file = tmp_path / "page.md"
|
|
563
|
+
md_file.write_text(md)
|
|
564
|
+
|
|
565
|
+
from confpub.confluence import ConfluenceClient
|
|
566
|
+
from unittest.mock import MagicMock
|
|
567
|
+
|
|
568
|
+
mock_client = MagicMock()
|
|
569
|
+
mock_client.get_page.return_value = None
|
|
570
|
+
mock_client.create_page.return_value = {"id": "new_1", "version": {"number": 1}}
|
|
571
|
+
mock_client.get_attachments.return_value = []
|
|
572
|
+
|
|
573
|
+
monkeypatch.setattr("confpub.publish.load_config", lambda: MagicMock())
|
|
574
|
+
monkeypatch.setattr("confpub.publish.ConfluenceClient", lambda cfg: mock_client)
|
|
575
|
+
|
|
576
|
+
result = runner.invoke(app, ["page", "publish", str(md_file)])
|
|
577
|
+
assert result.exit_code == 0, result.output
|
|
578
|
+
data = json.loads(result.output)
|
|
579
|
+
assert data["ok"] is True
|
|
580
|
+
assert data["result"]["changes"][0]["title"] == "FM Page"
|
|
581
|
+
|
|
582
|
+
def test_cli_flags_override_front_matter(self, tmp_path, monkeypatch):
|
|
583
|
+
"""CLI flags take precedence over front-matter values."""
|
|
584
|
+
md = "---\ntitle: FM Title\nspace: FMSPACE\nparent: FM Parent\n---\n\n# Content"
|
|
585
|
+
md_file = tmp_path / "page.md"
|
|
586
|
+
md_file.write_text(md)
|
|
587
|
+
|
|
588
|
+
from unittest.mock import MagicMock
|
|
589
|
+
|
|
590
|
+
mock_client = MagicMock()
|
|
591
|
+
mock_client.get_page.return_value = None
|
|
592
|
+
mock_client.create_page.return_value = {"id": "new_2", "version": {"number": 1}}
|
|
593
|
+
mock_client.get_attachments.return_value = []
|
|
594
|
+
|
|
595
|
+
monkeypatch.setattr("confpub.publish.load_config", lambda: MagicMock())
|
|
596
|
+
monkeypatch.setattr("confpub.publish.ConfluenceClient", lambda cfg: mock_client)
|
|
597
|
+
|
|
598
|
+
result = runner.invoke(app, [
|
|
599
|
+
"page", "publish", str(md_file),
|
|
600
|
+
"--space", "CLISPACE",
|
|
601
|
+
"--parent", "CLI Parent",
|
|
602
|
+
"--title", "CLI Title",
|
|
603
|
+
])
|
|
604
|
+
assert result.exit_code == 0, result.output
|
|
605
|
+
data = json.loads(result.output)
|
|
606
|
+
assert data["ok"] is True
|
|
607
|
+
assert data["result"]["changes"][0]["title"] == "CLI Title"
|
|
608
|
+
# Verify the CLI space was used (passed to create_page)
|
|
609
|
+
call_args = mock_client.create_page.call_args
|
|
610
|
+
assert call_args[0][0] == "CLISPACE"
|
|
611
|
+
|
|
612
|
+
def test_labels_merge_cli_and_front_matter(self, tmp_path, monkeypatch):
|
|
613
|
+
"""CLI labels and front-matter labels are merged (deduplicated)."""
|
|
614
|
+
md = "---\nspace: DEV\nparent: Docs\nlabels:\n - fm1\n - shared\n---\n\nContent"
|
|
615
|
+
md_file = tmp_path / "page.md"
|
|
616
|
+
md_file.write_text(md)
|
|
617
|
+
|
|
618
|
+
from unittest.mock import MagicMock
|
|
619
|
+
|
|
620
|
+
mock_client = MagicMock()
|
|
621
|
+
mock_client.get_page.return_value = None
|
|
622
|
+
mock_client.create_page.return_value = {"id": "new_3", "version": {"number": 1}}
|
|
623
|
+
mock_client.get_attachments.return_value = []
|
|
624
|
+
mock_client.set_labels.return_value = []
|
|
625
|
+
|
|
626
|
+
monkeypatch.setattr("confpub.publish.load_config", lambda: MagicMock())
|
|
627
|
+
monkeypatch.setattr("confpub.publish.ConfluenceClient", lambda cfg: mock_client)
|
|
628
|
+
|
|
629
|
+
result = runner.invoke(app, [
|
|
630
|
+
"page", "publish", str(md_file),
|
|
631
|
+
"--label", "cli1",
|
|
632
|
+
"--label", "shared",
|
|
633
|
+
])
|
|
634
|
+
assert result.exit_code == 0, result.output
|
|
635
|
+
data = json.loads(result.output)
|
|
636
|
+
assert data["ok"] is True
|
|
637
|
+
# Labels should be merged and deduplicated
|
|
638
|
+
labels_call = mock_client.set_labels.call_args[0][1]
|
|
639
|
+
assert "cli1" in labels_call
|
|
640
|
+
assert "fm1" in labels_call
|
|
641
|
+
assert "shared" in labels_call
|
|
642
|
+
# No duplicates
|
|
643
|
+
assert len(labels_call) == len(set(labels_call))
|
|
644
|
+
|
|
645
|
+
def test_no_front_matter_existing_behavior(self, tmp_path):
|
|
646
|
+
"""Without front-matter, missing --space still raises an error."""
|
|
647
|
+
md = "# Plain Page\n\nNo front-matter here."
|
|
648
|
+
md_file = tmp_path / "plain.md"
|
|
649
|
+
md_file.write_text(md)
|
|
650
|
+
|
|
651
|
+
result = runner.invoke(app, ["page", "publish", str(md_file)])
|
|
652
|
+
assert result.exit_code == 10
|
|
653
|
+
data = json.loads(result.output)
|
|
654
|
+
assert data["ok"] is False
|
|
655
|
+
assert "space" in data["errors"][0]["message"].lower()
|
|
@@ -65,6 +65,26 @@ class TestDeriveTitle:
|
|
|
65
65
|
assert derive_title(str(md_file), "Explicit Title", title_from_h1=True) == "Explicit Title"
|
|
66
66
|
|
|
67
67
|
|
|
68
|
+
class TestDeriveTitleFrontMatter:
|
|
69
|
+
def test_front_matter_title_used_as_fallback(self):
|
|
70
|
+
assert derive_title("some-file.md", front_matter_title="FM Title") == "FM Title"
|
|
71
|
+
|
|
72
|
+
def test_cli_title_wins_over_front_matter(self):
|
|
73
|
+
assert derive_title("some-file.md", "CLI Title", front_matter_title="FM Title") == "CLI Title"
|
|
74
|
+
|
|
75
|
+
def test_h1_wins_over_front_matter(self, tmp_path):
|
|
76
|
+
md_file = tmp_path / "test.md"
|
|
77
|
+
md_file.write_text("# H1 Title\n\nContent here.")
|
|
78
|
+
result = derive_title(str(md_file), title_from_h1=True, front_matter_title="FM Title")
|
|
79
|
+
assert result == "H1 Title"
|
|
80
|
+
|
|
81
|
+
def test_front_matter_beats_filename(self):
|
|
82
|
+
assert derive_title("my-cool-page.md", front_matter_title="Better Title") == "Better Title"
|
|
83
|
+
|
|
84
|
+
def test_no_front_matter_falls_through_to_filename(self):
|
|
85
|
+
assert derive_title("my-cool-page.md", front_matter_title=None) == "My Cool Page"
|
|
86
|
+
|
|
87
|
+
|
|
68
88
|
class TestPublishDryRun:
|
|
69
89
|
@patch("confpub.publish.load_config")
|
|
70
90
|
@patch("confpub.publish.ConfluenceClient")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|