mkdocs2confluence 0.19.0__tar.gz → 0.20.1__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 (111) hide show
  1. {mkdocs2confluence-0.19.0/src/mkdocs2confluence.egg-info → mkdocs2confluence-0.20.1}/PKG-INFO +1 -1
  2. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/pyproject.toml +1 -1
  3. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1/src/mkdocs2confluence.egg-info}/PKG-INFO +1 -1
  4. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/cli.py +4 -1
  5. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/emitter/xhtml.py +51 -2
  6. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/loader/config.py +29 -1
  7. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/parser/markdown.py +15 -3
  8. mkdocs2confluence-0.20.1/tests/test_admonition_titles.py +118 -0
  9. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_parser.py +49 -0
  10. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_publish_config.py +42 -0
  11. mkdocs2confluence-0.19.0/tests/test_admonition_titles.py +0 -62
  12. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/LICENSE +0 -0
  13. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/README.md +0 -0
  14. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/setup.cfg +0 -0
  15. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs2confluence.egg-info/SOURCES.txt +0 -0
  16. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs2confluence.egg-info/dependency_links.txt +0 -0
  17. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs2confluence.egg-info/entry_points.txt +0 -0
  18. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs2confluence.egg-info/requires.txt +0 -0
  19. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs2confluence.egg-info/top_level.txt +0 -0
  20. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/__init__.py +0 -0
  21. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/compiler/__init__.py +0 -0
  22. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/compiler/models.py +0 -0
  23. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/compiler/page.py +0 -0
  24. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/emitter/__init__.py +0 -0
  25. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/ir/__init__.py +0 -0
  26. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/ir/nodes.py +0 -0
  27. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/ir/treeutil.py +0 -0
  28. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/loader/__init__.py +0 -0
  29. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/loader/extra_css.py +0 -0
  30. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/loader/nav.py +0 -0
  31. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/loader/page.py +0 -0
  32. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/parser/__init__.py +0 -0
  33. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/pdf/__init__.py +0 -0
  34. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/pdf/generator.py +0 -0
  35. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/pdf/render.py +0 -0
  36. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/preprocess/__init__.py +0 -0
  37. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/preprocess/abbrevs.py +0 -0
  38. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/preprocess/captions.py +0 -0
  39. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/preprocess/fence.py +0 -0
  40. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/preprocess/frontmatter.py +0 -0
  41. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/preprocess/icons.py +0 -0
  42. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/preprocess/includes.py +0 -0
  43. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/preprocess/linkdefs.py +0 -0
  44. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/preview/__init__.py +0 -0
  45. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/preview/render.py +0 -0
  46. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/preview/server.py +0 -0
  47. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/publisher/__init__.py +0 -0
  48. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/publisher/changelog.py +0 -0
  49. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/publisher/client.py +0 -0
  50. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/publisher/executor.py +0 -0
  51. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/publisher/http_retry.py +0 -0
  52. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/publisher/models.py +0 -0
  53. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/publisher/planner.py +0 -0
  54. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/skill_installer.py +0 -0
  55. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/skills/mkdocs-changelog/SKILL.md +0 -0
  56. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/skills/mkdocs-changelog/scripts/changelog_data.py +0 -0
  57. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/sync/__init__.py +0 -0
  58. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/sync/anchoring.py +0 -0
  59. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/sync/command.py +0 -0
  60. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/sync/comments.py +0 -0
  61. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/sync/github.py +0 -0
  62. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/sync/platform.py +0 -0
  63. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/sync/state.py +0 -0
  64. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/transforms/__init__.py +0 -0
  65. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/transforms/_kroki.py +0 -0
  66. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/transforms/abbrevs.py +0 -0
  67. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/transforms/admonition_titles.py +0 -0
  68. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/transforms/assets.py +0 -0
  69. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/transforms/attachment_previews.py +0 -0
  70. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/transforms/captions.py +0 -0
  71. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/transforms/editlink.py +0 -0
  72. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/transforms/footer.py +0 -0
  73. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/transforms/images.py +0 -0
  74. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/transforms/internallinks.py +0 -0
  75. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/transforms/mermaid.py +0 -0
  76. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/src/mkdocs_to_confluence/transforms/plantuml.py +0 -0
  77. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_abbrevs.py +0 -0
  78. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_attachment_previews.py +0 -0
  79. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_captions.py +0 -0
  80. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_changelog_config.py +0 -0
  81. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_changelog_publish.py +0 -0
  82. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_children_macro.py +0 -0
  83. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_cli.py +0 -0
  84. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_editlink.py +0 -0
  85. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_emitter.py +0 -0
  86. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_exclude_properties_config.py +0 -0
  87. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_extra_css.py +0 -0
  88. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_footer.py +0 -0
  89. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_frontmatter.py +0 -0
  90. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_icons.py +0 -0
  91. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_images.py +0 -0
  92. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_internallinks.py +0 -0
  93. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_ir.py +0 -0
  94. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_linkdefs.py +0 -0
  95. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_loader.py +0 -0
  96. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_mermaid.py +0 -0
  97. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_page_loader.py +0 -0
  98. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_pdf.py +0 -0
  99. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_plantuml.py +0 -0
  100. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_preprocess.py +0 -0
  101. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_preview.py +0 -0
  102. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_publish_client.py +0 -0
  103. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_publish_pipeline.py +0 -0
  104. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_server.py +0 -0
  105. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_skill_installer.py +0 -0
  106. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_sync_anchoring.py +0 -0
  107. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_sync_command.py +0 -0
  108. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_sync_comments.py +0 -0
  109. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_sync_github.py +0 -0
  110. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_sync_state.py +0 -0
  111. {mkdocs2confluence-0.19.0 → mkdocs2confluence-0.20.1}/tests/test_treeutil.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.19.0
3
+ Version: 0.20.1
4
4
  Summary: Publish MkDocs Material pages to Confluence Cloud — admonitions, Mermaid diagrams, tabs, page properties and more
5
5
  Author: Anders Hybertz
6
6
  License: GPL-3.0-or-later
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mkdocs2confluence"
3
- version = "0.19.0"
3
+ version = "0.20.1"
4
4
  description = "Publish MkDocs Material pages to Confluence Cloud — admonitions, Mermaid diagrams, tabs, page properties and more"
5
5
  readme = "README.md"
6
6
  license = { text = "GPL-3.0-or-later" }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.19.0
3
+ Version: 0.20.1
4
4
  Summary: Publish MkDocs Material pages to Confluence Cloud — admonitions, Mermaid diagrams, tabs, page properties and more
5
5
  Author: Anders Hybertz
6
6
  License: GPL-3.0-or-later
@@ -10,7 +10,7 @@ from pathlib import Path
10
10
 
11
11
  from mkdocs_to_confluence import __version__
12
12
  from mkdocs_to_confluence.compiler.page import compile_page
13
- from mkdocs_to_confluence.emitter.xhtml import configure_styles
13
+ from mkdocs_to_confluence.emitter.xhtml import configure_admonitions, configure_styles
14
14
  from mkdocs_to_confluence.loader.config import load_config
15
15
  from mkdocs_to_confluence.loader.nav import find_section, find_section_by_folder, flat_pages, resolve_nav
16
16
  from mkdocs_to_confluence.loader.page import PageLoadError, find_page
@@ -340,6 +340,7 @@ def _cmd_preview(args: argparse.Namespace) -> None:
340
340
  config_path = Path(args.config).resolve()
341
341
  config = load_config(config_path)
342
342
  configure_styles(config.extra_styles)
343
+ configure_admonitions(config.confluence.admonitions if config.confluence else None)
343
344
 
344
345
  all_nodes = resolve_nav(config)
345
346
 
@@ -495,6 +496,7 @@ def _cmd_publish(args: argparse.Namespace) -> None:
495
496
  config_path = Path(args.config).resolve()
496
497
  config = load_config(config_path)
497
498
  configure_styles(config.extra_styles)
499
+ configure_admonitions(config.confluence.admonitions if config.confluence else None)
498
500
 
499
501
  conf_config = config.confluence
500
502
  if conf_config is None:
@@ -687,6 +689,7 @@ def _cmd_pdf(args: argparse.Namespace) -> None:
687
689
  config_path = Path(args.config).resolve()
688
690
  config = load_config(config_path)
689
691
  configure_styles(config.extra_styles)
692
+ configure_admonitions(config.confluence.admonitions if config.confluence else None)
690
693
 
691
694
  nodes = resolve_nav(config)
692
695
 
@@ -17,6 +17,7 @@ https://developer.atlassian.com/server/confluence/confluence-storage-format/
17
17
  from __future__ import annotations
18
18
 
19
19
  import html
20
+ import sys
20
21
  from pathlib import Path
21
22
  from typing import Sequence
22
23
 
@@ -77,6 +78,32 @@ def configure_styles(styles: ExtraStyles | None) -> None:
77
78
  global _styles
78
79
  _styles = styles
79
80
 
81
+
82
+ # ── Custom admonition aliases (set once per run via configure_admonitions) ─────
83
+
84
+ _admonition_aliases: dict[str, str] = {}
85
+
86
+
87
+ def configure_admonitions(aliases: dict[str, str] | None) -> None:
88
+ """Map custom admonition kinds onto built-in ones, from ``confluence.admonitions``.
89
+
90
+ Targets naming no built-in kind are kept but warned about — they resolve
91
+ through the same fallback as any unknown kind, so a typo degrades rather
92
+ than fails the build.
93
+ """
94
+ global _admonition_aliases
95
+ _admonition_aliases = dict(aliases or {})
96
+ unknown = sorted(
97
+ f"{k} -> {v}" for k, v in _admonition_aliases.items() if v not in _KNOWN_ADMONITION_KINDS
98
+ )
99
+ if unknown:
100
+ print(
101
+ "warning: confluence.admonitions targets an unknown admonition kind: "
102
+ + ", ".join(unknown)
103
+ + f". Known kinds: {', '.join(sorted(_KNOWN_ADMONITION_KINDS))}.",
104
+ file=sys.stderr,
105
+ )
106
+
80
107
  # ── Admonition kind → Confluence macro name ───────────────────────────────────
81
108
 
82
109
  # Types that map to Confluence's four native panel macros (include built-in icons).
@@ -106,6 +133,11 @@ _ADMONITION_MACRO: dict[str, str] = {
106
133
  _ADMONITION_DANGER_KINDS: frozenset[str] = frozenset(
107
134
  {"danger", "error", "bug", "failure", "fail", "missing"}
108
135
  )
136
+ # Every kind the emitter understands as an alias target.
137
+ _KNOWN_ADMONITION_KINDS: frozenset[str] = (
138
+ frozenset(_ADMONITION_MACRO) | _ADMONITION_DANGER_KINDS
139
+ )
140
+
109
141
  _DANGER_EMOJI = "🚨"
110
142
  _DANGER_COLOURS = {
111
143
  "borderColor": "#DE350B",
@@ -350,8 +382,25 @@ def _emit_code_block(node: CodeBlock) -> str:
350
382
  return "".join(parts)
351
383
 
352
384
 
385
+ def _resolve_admonition_kind(kind: str) -> str:
386
+ """Resolve *kind* to the built-in kind that decides its Confluence rendering.
387
+
388
+ Order: an explicit ``confluence.admonitions`` alias, then the built-in kinds,
389
+ then ``success`` for anything unrecognised.
390
+ """
391
+ alias = _admonition_aliases.get(kind)
392
+ if alias is not None:
393
+ return alias
394
+ if kind in _KNOWN_ADMONITION_KINDS:
395
+ return kind
396
+ return "success"
397
+
398
+
353
399
  def _emit_admonition(node: Admonition) -> str:
400
+ # The title keeps the author's own kind ("Value-driver"), only the styling
401
+ # follows the resolved kind.
354
402
  title = node.title or _DEFAULT_ADMONITION_TITLES.get(node.kind, node.kind.capitalize())
403
+ kind = _resolve_admonition_kind(node.kind)
355
404
  body = emit(node.children)
356
405
 
357
406
  # ??? and ???+ → Confluence expand macro (collapsible)
@@ -367,7 +416,7 @@ def _emit_admonition(node: Admonition) -> str:
367
416
 
368
417
  # Fall through for ???+ (expanded=True) — render as a regular admonition below.
369
418
 
370
- if node.kind in _ADMONITION_DANGER_KINDS:
419
+ if kind in _ADMONITION_DANGER_KINDS:
371
420
  colours = "".join(
372
421
  f' <ac:parameter ac:name="{k}">{v}</ac:parameter>\n'
373
422
  for k, v in _DANGER_COLOURS.items()
@@ -381,7 +430,7 @@ def _emit_admonition(node: Admonition) -> str:
381
430
  "</ac:structured-macro>\n"
382
431
  )
383
432
 
384
- macro_name = _ADMONITION_MACRO.get(node.kind, "info")
433
+ macro_name = _ADMONITION_MACRO.get(kind, "tip")
385
434
  return (
386
435
  f'<ac:structured-macro ac:name="{macro_name}">\n'
387
436
  f' <ac:parameter ac:name="title">{html.escape(title)}</ac:parameter>\n'
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import os
6
6
  import re
7
- from dataclasses import dataclass
7
+ from dataclasses import dataclass, field
8
8
  from pathlib import Path
9
9
  from typing import Any
10
10
  from urllib.parse import urlparse
@@ -38,6 +38,10 @@ class ConfluenceConfig:
38
38
  exclude_properties: tuple[str, ...] = () # front matter keys to omit from Page Properties table
39
39
  attachment_preview: bool = False # render PDF/Office attachment links as view-file macros
40
40
  children_macro: bool = True # append Children Display macro to section index pages
41
+ # Custom admonition kind → built-in kind it should render as, e.g.
42
+ # {"value-driver": "tip"}. Kinds absent here fall back to the built-in
43
+ # mapping, then to "success". Emitter owns the target vocabulary.
44
+ admonitions: dict[str, str] = field(default_factory=dict)
41
45
 
42
46
 
43
47
  @dataclass(frozen=True)
@@ -314,6 +318,29 @@ def load_config(mkdocs_yml: Path) -> MkDocsConfig:
314
318
  )
315
319
  children_macro = raw_children
316
320
 
321
+ # admonitions (optional) — map custom admonition kinds onto built-in ones.
322
+ # Only the shape is checked here; the emitter owns the target vocabulary
323
+ # and warns about unknown targets, since loader sits below it in the layering.
324
+ raw_admonitions = raw_conf.get("admonitions", {})
325
+ if not isinstance(raw_admonitions, dict):
326
+ raise ConfigError(
327
+ "mkdocs.yml: 'confluence.admonitions' must be a mapping of "
328
+ f"custom kind to built-in kind, got {type(raw_admonitions).__name__}."
329
+ )
330
+ admonitions: dict[str, str] = {}
331
+ for key, value in raw_admonitions.items():
332
+ if not isinstance(key, str) or not key.strip():
333
+ raise ConfigError(
334
+ "mkdocs.yml: 'confluence.admonitions' keys must be non-empty strings."
335
+ )
336
+ if not isinstance(value, str) or not value.strip():
337
+ raise ConfigError(
338
+ f"mkdocs.yml: 'confluence.admonitions.{key}' must be a non-empty "
339
+ "string naming a built-in admonition kind (e.g. 'tip', 'warning')."
340
+ )
341
+ # Admonition kinds are case-insensitive in Markdown; normalise both sides.
342
+ admonitions[key.strip().lower()] = value.strip().lower()
343
+
317
344
  confluence = ConfluenceConfig(
318
345
  base_url=base_url.rstrip("/"),
319
346
  space_key=space_key,
@@ -332,6 +359,7 @@ def load_config(mkdocs_yml: Path) -> MkDocsConfig:
332
359
  exclude_properties=exclude_properties,
333
360
  attachment_preview=attachment_preview,
334
361
  children_macro=children_macro,
362
+ admonitions=admonitions,
335
363
  )
336
364
 
337
365
  # --- extra_css (optional) ---
@@ -51,6 +51,7 @@ from __future__ import annotations
51
51
  import contextlib
52
52
  import html
53
53
  import re
54
+ import textwrap
54
55
  from dataclasses import dataclass, field
55
56
  from typing import Union
56
57
 
@@ -256,7 +257,7 @@ _FENCE_OPEN_RE = re.compile(r"^(?P<fence>`{3,}|~{3,})(?P<info>.*)$")
256
257
 
257
258
  # Matches an admonition opener: !!! kind / !!! kind "title" / ??? kind / ???+ kind
258
259
  _ADMONITION_RE = re.compile(
259
- r"""^[ ]{0,3}(?P<marker>!{3}|\?{3}\+?)\s+(?P<kind>\w+)(?:\s+(?P<q>["'])(?P<title>.*?)(?P=q))?\s*$"""
260
+ r"""^[ ]{0,3}(?P<marker>!{3}|\?{3}\+?)\s+(?P<kind>[\w-]+)(?:\s+(?P<q>["'])(?P<title>.*?)(?P=q))?\s*$"""
260
261
  )
261
262
 
262
263
  # Matches a Material for MkDocs content tab opener: === "Label"
@@ -329,6 +330,17 @@ def _grid_kind(line: str) -> str | None:
329
330
  _CARD_BULLET_RE = re.compile(r'^(?P<marker>[-*+]\s+)(?P<text>.*)$')
330
331
 
331
332
 
333
+ def _dedent_body(lines: list[str]) -> str:
334
+ """Join admonition/tab body *lines* and drop any indent they all share.
335
+
336
+ The collector strips the 4 columns that delimit the body, but authors often
337
+ indent the content further (8 spaces is a common habit). That residual indent
338
+ is invisible to the author and would stop a list, fence or heading from being
339
+ recognised, since those anchor at column 0.
340
+ """
341
+ return textwrap.dedent("\n".join(_expand_leading_tabs(ln) for ln in lines))
342
+
343
+
332
344
  def _expand_leading_tabs(line: str) -> str:
333
345
  """Expand tabs in *line*'s leading whitespace to 4-space tab stops.
334
346
 
@@ -536,7 +548,7 @@ def _tokenize(text: str) -> list[_Token]:
536
548
  # Drop trailing blank lines so the recursive tokenizer stays clean.
537
549
  while body_raw and not body_raw[-1].strip():
538
550
  body_raw.pop()
539
- body_tokens = _tokenize("\n".join(body_raw))
551
+ body_tokens = _tokenize(_dedent_body(body_raw))
540
552
  tokens.append(
541
553
  _AdmonitionToken(
542
554
  kind=kind,
@@ -571,7 +583,7 @@ def _tokenize(text: str) -> list[_Token]:
571
583
  break
572
584
  while body_raw_tab and not body_raw_tab[-1].strip():
573
585
  body_raw_tab.pop()
574
- tabs.append(_TabData(label=label, body_tokens=_tokenize("\n".join(body_raw_tab))))
586
+ tabs.append(_TabData(label=label, body_tokens=_tokenize(_dedent_body(body_raw_tab))))
575
587
  if tabs:
576
588
  tokens.append(_ContentTabsToken(tabs=tabs))
577
589
  continue
@@ -0,0 +1,118 @@
1
+ """Tests for the admonition-title link degradation transform."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mkdocs_to_confluence.ir.nodes import Admonition, Section, TextNode
6
+ from mkdocs_to_confluence.transforms.admonition_titles import (
7
+ strip_links_in_admonition_titles,
8
+ )
9
+
10
+
11
+ def _adm(title: str | None) -> Admonition:
12
+ return Admonition(kind="warning", title=title, children=())
13
+
14
+
15
+ def test_link_in_title_stripped_to_text() -> None:
16
+ nodes = (_adm("Conflict - see [Hello](foobar.md#hello)"),)
17
+ out = strip_links_in_admonition_titles(nodes, "index.md")
18
+ assert out[0].title == "Conflict - see Hello"
19
+
20
+
21
+ def test_warning_emitted_with_page_and_title(capsys) -> None:
22
+ nodes = (_adm("see [Hello](foobar.md#hello)"),)
23
+ strip_links_in_admonition_titles(nodes, "guide/index.md")
24
+ err = capsys.readouterr().err
25
+ assert "warning" in err
26
+ assert "guide/index.md" in err
27
+ assert "see [Hello](foobar.md#hello)" in err
28
+
29
+
30
+ def test_title_without_link_unchanged_and_no_warning(capsys) -> None:
31
+ nodes = (_adm("Just a plain title"),)
32
+ out = strip_links_in_admonition_titles(nodes, "index.md")
33
+ assert out[0].title == "Just a plain title"
34
+ assert capsys.readouterr().err == ""
35
+
36
+
37
+ def test_none_title_is_safe(capsys) -> None:
38
+ nodes = (_adm(None),)
39
+ out = strip_links_in_admonition_titles(nodes, "index.md")
40
+ assert out[0].title is None
41
+ assert capsys.readouterr().err == ""
42
+
43
+
44
+ def test_multiple_links_all_stripped() -> None:
45
+ nodes = (_adm("[A](a.md) and [B](b.md#x)"),)
46
+ out = strip_links_in_admonition_titles(nodes, "index.md")
47
+ assert out[0].title == "A and B"
48
+
49
+
50
+ def test_nested_admonition_in_section_processed() -> None:
51
+ inner = _adm("see [Hello](foobar.md#hello)")
52
+ section = Section(level=2, anchor="s", title=(TextNode("S"),), children=(inner,))
53
+ out = strip_links_in_admonition_titles((section,), "index.md")
54
+ nested = out[0].children[0]
55
+ assert isinstance(nested, Admonition)
56
+ assert nested.title == "see Hello"
57
+
58
+
59
+ def test_image_in_title_not_mangled() -> None:
60
+ nodes = (_adm("look ![alt](img.png)"),)
61
+ out = strip_links_in_admonition_titles(nodes, "index.md")
62
+ assert out[0].title == "look ![alt](img.png)"
63
+
64
+
65
+ class TestCustomAdmonitionKinds:
66
+ """`confluence.admonitions` config, then built-ins, then a `success` default."""
67
+
68
+ def setup_method(self) -> None:
69
+ from mkdocs_to_confluence.emitter.xhtml import configure_admonitions
70
+ configure_admonitions(None)
71
+
72
+ teardown_method = setup_method
73
+
74
+ def _macro(self, src: str) -> str:
75
+ import re
76
+
77
+ from mkdocs_to_confluence.emitter.xhtml import emit
78
+ from mkdocs_to_confluence.parser import parse
79
+ m = re.search(r'ac:name="(\w+)"', emit(parse(src)))
80
+ return m.group(1) if m else ""
81
+
82
+ def test_hyphenated_kind_parses_as_an_admonition(self) -> None:
83
+ """`!!! value-driver` must not fall through to a paragraph."""
84
+ assert self._macro('!!! value-driver\n\n Body.\n') != ""
85
+
86
+ def test_unknown_kind_defaults_to_success(self) -> None:
87
+ assert self._macro('!!! value-driver\n\n Body.\n') == "tip" # success -> tip
88
+
89
+ def test_builtin_kind_is_unaffected(self) -> None:
90
+ assert self._macro('!!! note\n\n Body.\n') == "info"
91
+ assert self._macro('!!! warning\n\n Body.\n') == "warning"
92
+
93
+ def test_config_alias_wins_over_the_default(self) -> None:
94
+ from mkdocs_to_confluence.emitter.xhtml import configure_admonitions
95
+ configure_admonitions({"value-driver": "warning"})
96
+ assert self._macro('!!! value-driver\n\n Body.\n') == "warning"
97
+
98
+ def test_config_alias_can_target_a_danger_panel(self) -> None:
99
+ from mkdocs_to_confluence.emitter.xhtml import configure_admonitions
100
+ configure_admonitions({"risk": "danger"})
101
+ assert self._macro('!!! risk\n\n Body.\n') == "panel"
102
+
103
+ def test_config_alias_can_override_a_builtin(self) -> None:
104
+ from mkdocs_to_confluence.emitter.xhtml import configure_admonitions
105
+ configure_admonitions({"note": "warning"})
106
+ assert self._macro('!!! note\n\n Body.\n') == "warning"
107
+
108
+ def test_title_keeps_the_authors_kind_not_the_alias(self) -> None:
109
+ from mkdocs_to_confluence.emitter.xhtml import configure_admonitions, emit
110
+ from mkdocs_to_confluence.parser import parse
111
+ configure_admonitions({"value-driver": "warning"})
112
+ assert "Value-driver" in emit(parse('!!! value-driver\n\n Body.\n'))
113
+
114
+ def test_unknown_alias_target_warns_and_degrades(self, capsys) -> None: # type: ignore[no-untyped-def]
115
+ from mkdocs_to_confluence.emitter.xhtml import configure_admonitions
116
+ configure_admonitions({"oops": "nonsense"})
117
+ assert "unknown admonition kind" in capsys.readouterr().err
118
+ assert self._macro('!!! oops\n\n Body.\n') == "tip"
@@ -1668,3 +1668,52 @@ class TestGridCardIndentEdgeCases:
1668
1668
  src = '<div class="grid cards" markdown>\r\n\r\n- !!! tip "T"\r\n\r\n Body.\r\n\r\n</div>\r\n'
1669
1669
  from mkdocs_to_confluence.emitter.xhtml import emit
1670
1670
  assert "Body." in self._panel_body(emit(parse(src)))
1671
+
1672
+
1673
+ class TestAdmonitionBodyOverIndent:
1674
+ """Body content indented past the 4 that delimit it must still parse."""
1675
+
1676
+ def _emit(self, src: str) -> str:
1677
+ from mkdocs_to_confluence.emitter.xhtml import emit
1678
+ return emit(parse(src))
1679
+
1680
+ def test_list_indented_eight_spaces(self) -> None:
1681
+ """8 spaces (4 for the body + 4 more) is a common authoring habit."""
1682
+ xhtml = self._emit('!!! note "T"\n\n - one\n - two\n')
1683
+ assert "<li><p>one</p></li>" in xhtml
1684
+ assert "<p>- one" not in xhtml, "list flattened to a paragraph"
1685
+
1686
+ def test_list_indented_four_spaces_still_works(self) -> None:
1687
+ xhtml = self._emit('!!! note "T"\n\n - one\n - two\n')
1688
+ assert "<li><p>one</p></li>" in xhtml
1689
+
1690
+ def test_nested_list_relative_indent_is_preserved(self) -> None:
1691
+ """Dedenting must not flatten a sub-list into its parent."""
1692
+ for sub_indent in (" ", " "): # 2 and 4 spaces past the item
1693
+ xhtml = self._emit(f'!!! note "T"\n\n - one\n{sub_indent}- sub\n - two\n')
1694
+ assert xhtml.count("<ul>") == 2, f"sub-list lost at {len(sub_indent)} spaces"
1695
+
1696
+ def test_mixed_indent_body_is_left_alone(self) -> None:
1697
+ """A deeper line beside a column-0 line keeps its relative indent."""
1698
+ xhtml = self._emit('!!! note "T"\n\n Intro.\n\n - deeper\n')
1699
+ assert "<p>Intro.</p>" in xhtml
1700
+
1701
+ def test_over_indented_code_fence(self) -> None:
1702
+ xhtml = self._emit('!!! note "T"\n\n ```python\n x = 1\n ```\n')
1703
+ assert "<![CDATA[x = 1]]>" in xhtml
1704
+
1705
+ def test_content_tab_body_is_dedented_too(self) -> None:
1706
+ xhtml = self._emit('=== "A"\n\n - one\n - two\n')
1707
+ assert "<li><p>one</p></li>" in xhtml
1708
+
1709
+ def test_grid_card_admonition_with_over_indented_list(self) -> None:
1710
+ """The reported case: grid card, bulleted admonition, list at 8 spaces."""
1711
+ xhtml = self._emit(
1712
+ '<div class="grid cards">\n\n'
1713
+ '- !!! abstract "Documentation"\n\n'
1714
+ ' - **Scope:** test\n'
1715
+ ' - **Out:** test\n\n'
1716
+ '</div>\n'
1717
+ )
1718
+ assert "<li><p><strong>Scope:</strong> test</p></li>" in xhtml
1719
+ assert "<p>- " not in xhtml
@@ -537,3 +537,45 @@ confluence:
537
537
  config = load_config(config_file)
538
538
  assert config.confluence is not None
539
539
  assert config.confluence.allow_any_host is False
540
+
541
+
542
+ class TestAdmonitionsConfig:
543
+ """`confluence.admonitions` parsing and validation."""
544
+
545
+ def _write(self, tmp_path, block: str): # type: ignore[no-untyped-def]
546
+ from mkdocs_to_confluence.loader.config import load_config
547
+ (tmp_path / "docs").mkdir(exist_ok=True)
548
+ (tmp_path / "mkdocs.yml").write_text(
549
+ "site_name: T\n"
550
+ "confluence:\n"
551
+ " base_url: https://x.atlassian.net\n"
552
+ " email: a@b.c\n"
553
+ " token: t\n"
554
+ " space_key: S\n" + block,
555
+ encoding="utf-8",
556
+ )
557
+ return load_config(tmp_path / "mkdocs.yml")
558
+
559
+ def test_absent_defaults_to_empty(self, tmp_path) -> None: # type: ignore[no-untyped-def]
560
+ cfg = self._write(tmp_path, "")
561
+ assert cfg.confluence is not None
562
+ assert cfg.confluence.admonitions == {}
563
+
564
+ def test_mapping_is_parsed_and_lowercased(self, tmp_path) -> None: # type: ignore[no-untyped-def]
565
+ cfg = self._write(tmp_path, " admonitions:\n Value-Driver: TIP\n risk: danger\n")
566
+ assert cfg.confluence is not None
567
+ assert cfg.confluence.admonitions == {"value-driver": "tip", "risk": "danger"}
568
+
569
+ def test_non_mapping_is_rejected(self, tmp_path) -> None: # type: ignore[no-untyped-def]
570
+ import pytest
571
+
572
+ from mkdocs_to_confluence.loader.config import ConfigError
573
+ with pytest.raises(ConfigError, match="must be a mapping"):
574
+ self._write(tmp_path, " admonitions:\n - tip\n")
575
+
576
+ def test_non_string_value_is_rejected(self, tmp_path) -> None: # type: ignore[no-untyped-def]
577
+ import pytest
578
+
579
+ from mkdocs_to_confluence.loader.config import ConfigError
580
+ with pytest.raises(ConfigError, match="non-empty"):
581
+ self._write(tmp_path, " admonitions:\n risk: 5\n")
@@ -1,62 +0,0 @@
1
- """Tests for the admonition-title link degradation transform."""
2
-
3
- from __future__ import annotations
4
-
5
- from mkdocs_to_confluence.ir.nodes import Admonition, Section, TextNode
6
- from mkdocs_to_confluence.transforms.admonition_titles import (
7
- strip_links_in_admonition_titles,
8
- )
9
-
10
-
11
- def _adm(title: str | None) -> Admonition:
12
- return Admonition(kind="warning", title=title, children=())
13
-
14
-
15
- def test_link_in_title_stripped_to_text() -> None:
16
- nodes = (_adm("Conflict - see [Hello](foobar.md#hello)"),)
17
- out = strip_links_in_admonition_titles(nodes, "index.md")
18
- assert out[0].title == "Conflict - see Hello"
19
-
20
-
21
- def test_warning_emitted_with_page_and_title(capsys) -> None:
22
- nodes = (_adm("see [Hello](foobar.md#hello)"),)
23
- strip_links_in_admonition_titles(nodes, "guide/index.md")
24
- err = capsys.readouterr().err
25
- assert "warning" in err
26
- assert "guide/index.md" in err
27
- assert "see [Hello](foobar.md#hello)" in err
28
-
29
-
30
- def test_title_without_link_unchanged_and_no_warning(capsys) -> None:
31
- nodes = (_adm("Just a plain title"),)
32
- out = strip_links_in_admonition_titles(nodes, "index.md")
33
- assert out[0].title == "Just a plain title"
34
- assert capsys.readouterr().err == ""
35
-
36
-
37
- def test_none_title_is_safe(capsys) -> None:
38
- nodes = (_adm(None),)
39
- out = strip_links_in_admonition_titles(nodes, "index.md")
40
- assert out[0].title is None
41
- assert capsys.readouterr().err == ""
42
-
43
-
44
- def test_multiple_links_all_stripped() -> None:
45
- nodes = (_adm("[A](a.md) and [B](b.md#x)"),)
46
- out = strip_links_in_admonition_titles(nodes, "index.md")
47
- assert out[0].title == "A and B"
48
-
49
-
50
- def test_nested_admonition_in_section_processed() -> None:
51
- inner = _adm("see [Hello](foobar.md#hello)")
52
- section = Section(level=2, anchor="s", title=(TextNode("S"),), children=(inner,))
53
- out = strip_links_in_admonition_titles((section,), "index.md")
54
- nested = out[0].children[0]
55
- assert isinstance(nested, Admonition)
56
- assert nested.title == "see Hello"
57
-
58
-
59
- def test_image_in_title_not_mangled() -> None:
60
- nodes = (_adm("look ![alt](img.png)"),)
61
- out = strip_links_in_admonition_titles(nodes, "index.md")
62
- assert out[0].title == "look ![alt](img.png)"