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.
Files changed (54) hide show
  1. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/PKG-INFO +1 -1
  2. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/__init__.py +1 -1
  3. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/cli.py +43 -14
  4. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/converter.py +23 -0
  5. confpub_cli-1.7.0/confpub/front_matter.py +99 -0
  6. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/guide.py +52 -7
  7. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/publish.py +11 -3
  8. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_converter.py +30 -1
  9. confpub_cli-1.7.0/tests/test_front_matter.py +98 -0
  10. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_guide.py +1 -1
  11. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_integration.py +102 -0
  12. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_publish.py +20 -0
  13. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/.github/workflows/publish.yml +0 -0
  14. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/.gitignore +0 -0
  15. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/CLAUDE.md +0 -0
  16. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/LICENSE +0 -0
  17. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/PRD.md +0 -0
  18. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/README.md +0 -0
  19. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/applier.py +0 -0
  20. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/assets.py +0 -0
  21. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/config.py +0 -0
  22. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/confluence.py +0 -0
  23. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/envelope.py +0 -0
  24. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/errors.py +0 -0
  25. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/lockfile.py +0 -0
  26. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/macro_plugin.py +0 -0
  27. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/manifest.py +0 -0
  28. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/output.py +0 -0
  29. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/planner.py +0 -0
  30. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/puller.py +0 -0
  31. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/py.typed +0 -0
  32. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/reverse_converter.py +0 -0
  33. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/validator.py +0 -0
  34. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub/verifier.py +0 -0
  35. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/confpub.lock +0 -0
  36. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/pyproject.toml +0 -0
  37. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/__init__.py +0 -0
  38. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/conftest.py +0 -0
  39. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_applier.py +0 -0
  40. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_assets.py +0 -0
  41. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_config.py +0 -0
  42. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_confluence.py +0 -0
  43. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_envelope.py +0 -0
  44. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_errors.py +0 -0
  45. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_lockfile.py +0 -0
  46. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_macro_plugin.py +0 -0
  47. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_manifest.py +0 -0
  48. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_output.py +0 -0
  49. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_planner.py +0 -0
  50. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_puller.py +0 -0
  51. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_reverse_converter.py +0 -0
  52. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_validator.py +0 -0
  53. {confpub_cli-1.6.0 → confpub_cli-1.7.0}/tests/test_verifier.py +0 -0
  54. {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.6.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
@@ -1,3 +1,3 @@
1
1
  """confpub — Agent-first CLI to publish Markdown to Confluence."""
2
2
 
3
- __version__ = "1.6.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
@@ -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 (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,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": "---\\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",
@@ -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(file: str, title: str | None = None, *, title_from_h1: bool = False) -> str:
27
- """Derive page title from explicit title, H1 heading, or filename.
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 "title-cased" in cmd["agent_hint"]
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