mkdocs2confluence 0.11.1__tar.gz → 0.12.2__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 (95) hide show
  1. {mkdocs2confluence-0.11.1/src/mkdocs2confluence.egg-info → mkdocs2confluence-0.12.2}/PKG-INFO +9 -2
  2. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/README.md +7 -1
  3. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/pyproject.toml +2 -1
  4. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2/src/mkdocs2confluence.egg-info}/PKG-INFO +9 -2
  5. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs2confluence.egg-info/SOURCES.txt +7 -0
  6. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs2confluence.egg-info/requires.txt +1 -0
  7. mkdocs2confluence-0.12.2/src/mkdocs_to_confluence/compiler/__init__.py +6 -0
  8. mkdocs2confluence-0.12.2/src/mkdocs_to_confluence/compiler/models.py +17 -0
  9. mkdocs2confluence-0.12.2/src/mkdocs_to_confluence/compiler/page.py +110 -0
  10. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/loader/config.py +25 -0
  11. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/loader/nav.py +8 -1
  12. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/publisher/client.py +31 -54
  13. mkdocs2confluence-0.12.2/src/mkdocs_to_confluence/publisher/executor.py +378 -0
  14. mkdocs2confluence-0.12.2/src/mkdocs_to_confluence/publisher/http_retry.py +50 -0
  15. mkdocs2confluence-0.12.2/src/mkdocs_to_confluence/publisher/models.py +60 -0
  16. mkdocs2confluence-0.12.2/src/mkdocs_to_confluence/publisher/pipeline.py +46 -0
  17. mkdocs2confluence-0.12.2/src/mkdocs_to_confluence/publisher/planner.py +308 -0
  18. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/transforms/assets.py +4 -3
  19. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_images.py +41 -0
  20. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_loader.py +70 -0
  21. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_publish_client.py +25 -24
  22. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_publish_config.py +71 -0
  23. mkdocs2confluence-0.11.1/src/mkdocs_to_confluence/publisher/pipeline.py +0 -838
  24. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/LICENSE +0 -0
  25. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/setup.cfg +0 -0
  26. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs2confluence.egg-info/dependency_links.txt +0 -0
  27. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs2confluence.egg-info/entry_points.txt +0 -0
  28. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs2confluence.egg-info/top_level.txt +0 -0
  29. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/__init__.py +0 -0
  30. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/cli.py +0 -0
  31. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/emitter/__init__.py +0 -0
  32. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/emitter/xhtml.py +0 -0
  33. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/ir/__init__.py +0 -0
  34. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/ir/document.py +0 -0
  35. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/ir/nodes.py +0 -0
  36. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/ir/treeutil.py +0 -0
  37. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/loader/__init__.py +0 -0
  38. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/loader/extra_css.py +0 -0
  39. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/loader/page.py +0 -0
  40. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/parser/__init__.py +0 -0
  41. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/parser/markdown.py +0 -0
  42. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/pdf/__init__.py +0 -0
  43. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/pdf/generator.py +0 -0
  44. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/pdf/render.py +0 -0
  45. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/preprocess/__init__.py +0 -0
  46. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/preprocess/abbrevs.py +0 -0
  47. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/preprocess/fence.py +0 -0
  48. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/preprocess/frontmatter.py +0 -0
  49. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/preprocess/icons.py +0 -0
  50. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/preprocess/includes.py +0 -0
  51. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/preprocess/linkdefs.py +0 -0
  52. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/preview/__init__.py +0 -0
  53. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/preview/render.py +0 -0
  54. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/preview/server.py +0 -0
  55. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/publisher/__init__.py +0 -0
  56. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/sync/__init__.py +0 -0
  57. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/sync/anchoring.py +0 -0
  58. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/sync/command.py +0 -0
  59. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/sync/comments.py +0 -0
  60. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/sync/github.py +0 -0
  61. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/sync/platform.py +0 -0
  62. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/sync/state.py +0 -0
  63. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/transforms/__init__.py +0 -0
  64. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/transforms/abbrevs.py +0 -0
  65. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/transforms/editlink.py +0 -0
  66. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/transforms/footer.py +0 -0
  67. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/transforms/images.py +0 -0
  68. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/transforms/internallinks.py +0 -0
  69. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/src/mkdocs_to_confluence/transforms/mermaid.py +0 -0
  70. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_abbrevs.py +0 -0
  71. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_children_macro.py +0 -0
  72. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_cli.py +0 -0
  73. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_editlink.py +0 -0
  74. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_emitter.py +0 -0
  75. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_extra_css.py +0 -0
  76. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_footer.py +0 -0
  77. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_frontmatter.py +0 -0
  78. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_icons.py +0 -0
  79. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_internallinks.py +0 -0
  80. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_ir.py +0 -0
  81. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_linkdefs.py +0 -0
  82. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_mermaid.py +0 -0
  83. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_page_loader.py +0 -0
  84. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_parser.py +0 -0
  85. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_pdf.py +0 -0
  86. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_preprocess.py +0 -0
  87. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_preview.py +0 -0
  88. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_publish_pipeline.py +0 -0
  89. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_server.py +0 -0
  90. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_sync_anchoring.py +0 -0
  91. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_sync_command.py +0 -0
  92. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_sync_comments.py +0 -0
  93. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_sync_github.py +0 -0
  94. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_sync_state.py +0 -0
  95. {mkdocs2confluence-0.11.1 → mkdocs2confluence-0.12.2}/tests/test_treeutil.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.11.1
3
+ Version: 0.12.2
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
@@ -25,6 +25,7 @@ Description-Content-Type: text/markdown
25
25
  License-File: LICENSE
26
26
  Requires-Dist: PyYAML>=6.0.3
27
27
  Requires-Dist: httpx>=0.27
28
+ Requires-Dist: idna>=3.15
28
29
  Requires-Dist: tinycss2>=1.5.1
29
30
  Provides-Extra: pdf
30
31
  Requires-Dist: weasyprint>=60.0; extra == "pdf"
@@ -181,7 +182,13 @@ mk2conf publish # go live
181
182
 
182
183
  ![Architecture](https://raw.githubusercontent.com/jeckyl2010/mkdocs2confluence/main/docs/architecture.png)
183
184
 
184
- Pipeline stages: **loader → preprocess → IR → transforms → emitter → publisher**. The plan phase makes all API read calls; the execute phase makes all write calls in nav order so parent pages always exist before their children.
185
+ Pipeline stages: **loader → preprocess → IR → transforms → emitter → publisher**.
186
+
187
+ The publisher is split into two phases:
188
+ - `planner.py` builds a nav-ordered publish plan, compiles pages, and makes the read-side API calls needed to decide create vs update vs skip.
189
+ - `executor.py` applies that plan, performs the write-side API calls, uploads attachments, and wires parent/child relationships in nav order so parent pages always exist before their children.
190
+
191
+ `publisher/pipeline.py` remains a compatibility facade that re-exports the public publish surface used by the CLI and tests.
185
192
 
186
193
  ---
187
194
 
@@ -140,7 +140,13 @@ mk2conf publish # go live
140
140
 
141
141
  ![Architecture](https://raw.githubusercontent.com/jeckyl2010/mkdocs2confluence/main/docs/architecture.png)
142
142
 
143
- Pipeline stages: **loader → preprocess → IR → transforms → emitter → publisher**. The plan phase makes all API read calls; the execute phase makes all write calls in nav order so parent pages always exist before their children.
143
+ Pipeline stages: **loader → preprocess → IR → transforms → emitter → publisher**.
144
+
145
+ The publisher is split into two phases:
146
+ - `planner.py` builds a nav-ordered publish plan, compiles pages, and makes the read-side API calls needed to decide create vs update vs skip.
147
+ - `executor.py` applies that plan, performs the write-side API calls, uploads attachments, and wires parent/child relationships in nav order so parent pages always exist before their children.
148
+
149
+ `publisher/pipeline.py` remains a compatibility facade that re-exports the public publish surface used by the CLI and tests.
144
150
 
145
151
  ---
146
152
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mkdocs2confluence"
3
- version = "0.11.1"
3
+ version = "0.12.2"
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" }
@@ -32,6 +32,7 @@ classifiers = [
32
32
  dependencies = [
33
33
  "PyYAML>=6.0.3",
34
34
  "httpx>=0.27",
35
+ "idna>=3.15",
35
36
  "tinycss2>=1.5.1",
36
37
  ]
37
38
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.11.1
3
+ Version: 0.12.2
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
@@ -25,6 +25,7 @@ Description-Content-Type: text/markdown
25
25
  License-File: LICENSE
26
26
  Requires-Dist: PyYAML>=6.0.3
27
27
  Requires-Dist: httpx>=0.27
28
+ Requires-Dist: idna>=3.15
28
29
  Requires-Dist: tinycss2>=1.5.1
29
30
  Provides-Extra: pdf
30
31
  Requires-Dist: weasyprint>=60.0; extra == "pdf"
@@ -181,7 +182,13 @@ mk2conf publish # go live
181
182
 
182
183
  ![Architecture](https://raw.githubusercontent.com/jeckyl2010/mkdocs2confluence/main/docs/architecture.png)
183
184
 
184
- Pipeline stages: **loader → preprocess → IR → transforms → emitter → publisher**. The plan phase makes all API read calls; the execute phase makes all write calls in nav order so parent pages always exist before their children.
185
+ Pipeline stages: **loader → preprocess → IR → transforms → emitter → publisher**.
186
+
187
+ The publisher is split into two phases:
188
+ - `planner.py` builds a nav-ordered publish plan, compiles pages, and makes the read-side API calls needed to decide create vs update vs skip.
189
+ - `executor.py` applies that plan, performs the write-side API calls, uploads attachments, and wires parent/child relationships in nav order so parent pages always exist before their children.
190
+
191
+ `publisher/pipeline.py` remains a compatibility facade that re-exports the public publish surface used by the CLI and tests.
185
192
 
186
193
  ---
187
194
 
@@ -9,6 +9,9 @@ src/mkdocs2confluence.egg-info/requires.txt
9
9
  src/mkdocs2confluence.egg-info/top_level.txt
10
10
  src/mkdocs_to_confluence/__init__.py
11
11
  src/mkdocs_to_confluence/cli.py
12
+ src/mkdocs_to_confluence/compiler/__init__.py
13
+ src/mkdocs_to_confluence/compiler/models.py
14
+ src/mkdocs_to_confluence/compiler/page.py
12
15
  src/mkdocs_to_confluence/emitter/__init__.py
13
16
  src/mkdocs_to_confluence/emitter/xhtml.py
14
17
  src/mkdocs_to_confluence/ir/__init__.py
@@ -37,7 +40,11 @@ src/mkdocs_to_confluence/preview/render.py
37
40
  src/mkdocs_to_confluence/preview/server.py
38
41
  src/mkdocs_to_confluence/publisher/__init__.py
39
42
  src/mkdocs_to_confluence/publisher/client.py
43
+ src/mkdocs_to_confluence/publisher/executor.py
44
+ src/mkdocs_to_confluence/publisher/http_retry.py
45
+ src/mkdocs_to_confluence/publisher/models.py
40
46
  src/mkdocs_to_confluence/publisher/pipeline.py
47
+ src/mkdocs_to_confluence/publisher/planner.py
41
48
  src/mkdocs_to_confluence/sync/__init__.py
42
49
  src/mkdocs_to_confluence/sync/anchoring.py
43
50
  src/mkdocs_to_confluence/sync/command.py
@@ -1,5 +1,6 @@
1
1
  PyYAML>=6.0.3
2
2
  httpx>=0.27
3
+ idna>=3.15
3
4
  tinycss2>=1.5.1
4
5
 
5
6
  [dev]
@@ -0,0 +1,6 @@
1
+ """Compiler entry points for MkDocs-to-Confluence page compilation."""
2
+
3
+ from mkdocs_to_confluence.compiler.models import CompileResult
4
+ from mkdocs_to_confluence.compiler.page import compile_page
5
+
6
+ __all__ = ["CompileResult", "compile_page"]
@@ -0,0 +1,17 @@
1
+ """Typed models for compiler outputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class CompileResult:
11
+ """Result of compiling a single MkDocs page to Confluence storage XHTML."""
12
+
13
+ xhtml: str
14
+ attachments: list[Path] = field(default_factory=list)
15
+ labels: tuple[str, ...] = ()
16
+ confluence_status: str | None = None
17
+ version_message: str | None = None
@@ -0,0 +1,110 @@
1
+ """Page compilation pipeline for MkDocs-to-Confluence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mkdocs_to_confluence.compiler.models import CompileResult
6
+ from mkdocs_to_confluence.emitter.xhtml import emit
7
+ from mkdocs_to_confluence.ir.nodes import ChildrenMacro, FrontMatter, SourceFooter
8
+ from mkdocs_to_confluence.loader.config import MkDocsConfig
9
+ from mkdocs_to_confluence.loader.nav import NavNode
10
+ from mkdocs_to_confluence.loader.page import load_page
11
+ from mkdocs_to_confluence.parser.markdown import parse
12
+ from mkdocs_to_confluence.preprocess.abbrevs import (
13
+ extract_abbreviations,
14
+ strip_abbreviation_defs,
15
+ )
16
+ from mkdocs_to_confluence.preprocess.frontmatter import extract_front_matter
17
+ from mkdocs_to_confluence.preprocess.icons import strip_icon_shortcodes
18
+ from mkdocs_to_confluence.preprocess.includes import (
19
+ preprocess_includes,
20
+ strip_html_comments,
21
+ strip_unsupported_html,
22
+ )
23
+ from mkdocs_to_confluence.preprocess.linkdefs import (
24
+ collect_link_defs,
25
+ expand_link_refs,
26
+ strip_link_defs,
27
+ )
28
+ from mkdocs_to_confluence.transforms.abbrevs import apply_abbreviations
29
+ from mkdocs_to_confluence.transforms.assets import resolve_local_assets
30
+ from mkdocs_to_confluence.transforms.editlink import attach_source_url
31
+ from mkdocs_to_confluence.transforms.footer import build_source_footer
32
+ from mkdocs_to_confluence.transforms.internallinks import resolve_internal_links
33
+ from mkdocs_to_confluence.transforms.mermaid import DEFAULT_KROKI_URL, render_mermaid_diagrams
34
+
35
+
36
+ def compile_page(
37
+ node: NavNode,
38
+ config: MkDocsConfig,
39
+ link_map: dict[str, str] | None = None,
40
+ *,
41
+ is_section_index: bool = False,
42
+ quiet: bool = False,
43
+ ) -> CompileResult:
44
+ """Run the full compile pipeline for one page and return a typed result."""
45
+ if node.source_path is None:
46
+ return CompileResult(xhtml="")
47
+
48
+ raw = load_page(node)
49
+
50
+ preprocessed = preprocess_includes(
51
+ raw,
52
+ source_path=node.source_path,
53
+ docs_dir=config.docs_dir,
54
+ )
55
+ preprocessed = strip_unsupported_html(preprocessed)
56
+ preprocessed = strip_html_comments(preprocessed)
57
+ preprocessed = strip_icon_shortcodes(preprocessed)
58
+ front_matter, preprocessed = extract_front_matter(preprocessed)
59
+ abbrevs = extract_abbreviations(preprocessed)
60
+ preprocessed = strip_abbreviation_defs(preprocessed)
61
+ link_defs = collect_link_defs(preprocessed)
62
+ preprocessed = expand_link_refs(preprocessed, link_defs)
63
+ preprocessed = strip_link_defs(preprocessed)
64
+ ir_nodes = parse(preprocessed)
65
+ if is_section_index:
66
+ ir_nodes = ir_nodes + (ChildrenMacro(),)
67
+ ir_nodes = apply_abbreviations(ir_nodes, abbrevs, page_text=preprocessed)
68
+ ir_nodes, attachments = resolve_local_assets(
69
+ ir_nodes,
70
+ page_path=node.source_path,
71
+ docs_dir=config.docs_dir,
72
+ )
73
+ mermaid_render = config.confluence.mermaid_render if config.confluence else "kroki"
74
+ if mermaid_render != "none":
75
+ kroki_url = (
76
+ mermaid_render[len("kroki:"):] if mermaid_render.startswith("kroki:") else DEFAULT_KROKI_URL
77
+ )
78
+ ir_nodes, mermaid_attachments = render_mermaid_diagrams(ir_nodes, kroki_url, quiet=quiet)
79
+ attachments = attachments + mermaid_attachments
80
+ effective_link_map = link_map if link_map is not None else {}
81
+ if node.docs_path:
82
+ ir_nodes = resolve_internal_links(ir_nodes, effective_link_map, node.docs_path)
83
+ if front_matter is not None:
84
+ ir_nodes = (front_matter,) + ir_nodes
85
+ edit_url = config.page_edit_url(node.docs_path or "")
86
+ site_url = config.page_site_url(node.docs_path or "")
87
+ if site_url:
88
+ ir_nodes = attach_source_url(ir_nodes, "", site_url)
89
+ if edit_url:
90
+ abs_path = str(config.docs_dir / (node.docs_path or ""))
91
+ footer = build_source_footer(edit_url, abs_path)
92
+ ir_nodes = ir_nodes + (footer,)
93
+
94
+ labels: tuple[str, ...] = ()
95
+ confluence_status: str | None = None
96
+ version_message: str | None = None
97
+ for node_item in ir_nodes:
98
+ if isinstance(node_item, FrontMatter):
99
+ labels = node_item.labels
100
+ confluence_status = node_item.confluence_status
101
+ if isinstance(node_item, SourceFooter) and node_item.commit_sha and node_item.commit_summary:
102
+ version_message = f"{node_item.commit_sha}: {node_item.commit_summary}"
103
+
104
+ return CompileResult(
105
+ xhtml=emit(ir_nodes),
106
+ attachments=attachments,
107
+ labels=labels,
108
+ confluence_status=confluence_status,
109
+ version_message=version_message,
110
+ )
@@ -33,6 +33,7 @@ class ConfluenceConfig:
33
33
  github_repo: str | None = None # "owner/repo" — required for sync-comments
34
34
  github_token: str | None = None # GitHub PAT (falls back to GITHUB_TOKEN env var)
35
35
  github_base_branch: str = "main" # base branch for review PRs
36
+ allow_any_host: bool = False # set True to allow non-Atlassian Cloud base_url hosts
36
37
 
37
38
 
38
39
  @dataclass(frozen=True)
@@ -209,6 +210,29 @@ def load_config(mkdocs_yml: Path) -> MkDocsConfig:
209
210
  if not isinstance(base_url, str) or not base_url.strip():
210
211
  raise ConfigError("mkdocs.yml: 'confluence.base_url' is required and must be a non-empty string.")
211
212
 
213
+ # Security: require HTTPS so credentials are never sent in plaintext.
214
+ parsed_url = urlparse(base_url.strip())
215
+ if parsed_url.scheme != "https":
216
+ raise ConfigError(
217
+ "mkdocs.yml: 'confluence.base_url' must use HTTPS (got "
218
+ f"{parsed_url.scheme!r}). Plain HTTP would transmit credentials in "
219
+ "cleartext."
220
+ )
221
+
222
+ allow_any_host = bool(raw_conf.get("allow_any_host", False))
223
+
224
+ # Security: guard against a repo-controlled base_url redirecting credentials
225
+ # to an attacker host. Non-Atlassian Cloud hosts require an explicit opt-in.
226
+ host = parsed_url.hostname or ""
227
+ _is_atlassian = host == "atlassian.net" or host.endswith(".atlassian.net")
228
+ if not _is_atlassian and not allow_any_host:
229
+ raise ConfigError(
230
+ f"mkdocs.yml: 'confluence.base_url' host {host!r} is not an "
231
+ "Atlassian Cloud domain (*.atlassian.net). If you are using a "
232
+ "self-hosted Confluence instance, add 'allow_any_host: true' under "
233
+ "the 'confluence:' block to acknowledge this."
234
+ )
235
+
212
236
  space_key: str | None = raw_conf.get("space_key") or None
213
237
  if space_key:
214
238
  space_key = space_key.strip() or None
@@ -248,6 +272,7 @@ def load_config(mkdocs_yml: Path) -> MkDocsConfig:
248
272
  github_token=(str(raw_conf["github_token"]) if raw_conf.get("github_token")
249
273
  else os.environ.get("GITHUB_TOKEN") or None),
250
274
  github_base_branch=str(raw_conf.get("github_base_branch", "main")),
275
+ allow_any_host=allow_any_host,
251
276
  )
252
277
 
253
278
  # --- extra_css (optional) ---
@@ -220,7 +220,14 @@ def _traverse(nav: list[Any], docs_dir: Path, level: int, nav_file: str = ".page
220
220
  elif isinstance(value, str):
221
221
  # Could be a page file or a directory reference (awesome-pages style)
222
222
  target = (docs_dir / value).resolve()
223
- if target.is_dir():
223
+ docs_root = docs_dir.resolve()
224
+ if not target.is_relative_to(docs_root):
225
+ warnings.warn(
226
+ f"Nav page '{title}' resolves outside docs_dir ('{target}') — "
227
+ "it will be omitted from the resolved nav.",
228
+ stacklevel=4,
229
+ )
230
+ elif target.is_dir():
224
231
  children = _resolve_nav_dir(target, docs_dir, level + 1, nav_file)
225
232
  nodes.append(
226
233
  NavNode(
@@ -7,9 +7,6 @@ Authentication is HTTP Basic with email + API token.
7
7
  from __future__ import annotations
8
8
 
9
9
  import base64
10
- import random
11
- import time
12
- from collections.abc import Callable
13
10
  from pathlib import Path
14
11
  from typing import Any, cast
15
12
  from urllib.parse import parse_qs, urlparse
@@ -17,9 +14,10 @@ from urllib.parse import parse_qs, urlparse
17
14
  import httpx
18
15
 
19
16
  from mkdocs_to_confluence.loader.config import ConfluenceConfig
17
+ from mkdocs_to_confluence.publisher.http_retry import ConfluenceError, http_request_with_retry
20
18
 
21
- _MAX_RETRIES = 3
22
- _RETRY_AFTER_CAP = 60.0 # seconds — cap Retry-After to avoid indefinite stalls
19
+ # Re-export so existing callers (`from publisher.client import ConfluenceError`) keep working.
20
+ __all__ = ["ConfluenceClient", "ConfluenceError"]
23
21
 
24
22
 
25
23
  def _extract_cursor(next_url: str) -> str:
@@ -29,10 +27,6 @@ def _extract_cursor(next_url: str) -> str:
29
27
  return cursors[0] if cursors else ""
30
28
 
31
29
 
32
- class ConfluenceError(RuntimeError):
33
- """Raised when the Confluence API returns an unexpected response."""
34
-
35
-
36
30
  class ConfluenceClient:
37
31
  """Thin HTTP wrapper around the Confluence Cloud REST API."""
38
32
 
@@ -92,35 +86,6 @@ class ConfluenceClient:
92
86
  f"{context}: HTTP {response.status_code} — {response.text[:400]}"
93
87
  )
94
88
 
95
- def _request(self, fn: Callable[[], httpx.Response], context: str) -> httpx.Response:
96
- """Call *fn* and retry up to ``_MAX_RETRIES`` times on HTTP 429.
97
-
98
- Respects the ``Retry-After`` response header (capped at
99
- ``_RETRY_AFTER_CAP`` seconds). Falls back to exponential backoff with
100
- jitter when the header is absent or invalid. Prints a warning on each
101
- retry. Raises :class:`ConfluenceError` if all retries are exhausted.
102
- """
103
- for attempt in range(_MAX_RETRIES + 1):
104
- resp = fn()
105
- if resp.status_code != 429:
106
- return resp
107
- if attempt == _MAX_RETRIES:
108
- raise ConfluenceError(
109
- f"{context}: rate-limited after {_MAX_RETRIES} retries — giving up"
110
- )
111
- header = resp.headers.get("Retry-After", "")
112
- try:
113
- wait = min(float(header), _RETRY_AFTER_CAP)
114
- except ValueError:
115
- wait = min(2.0 ** attempt + random.uniform(0.0, 1.0), _RETRY_AFTER_CAP)
116
- print(
117
- f" ⚠ rate-limited ({context}) — retrying in {wait:.1f}s"
118
- f" (attempt {attempt + 1}/{_MAX_RETRIES})",
119
- flush=True,
120
- )
121
- time.sleep(wait)
122
- raise ConfluenceError(f"{context}: rate-limited") # unreachable
123
-
124
89
  # ── Space ──────────────────────────────────────────────────────────────────
125
90
 
126
91
  def get_space_id(self, space_key: str) -> str:
@@ -228,7 +193,10 @@ class ConfluenceClient:
228
193
  payload: dict[str, Any] = {"spaceId": space_id, "title": title}
229
194
  if parent_id is not None:
230
195
  payload["parentId"] = parent_id
231
- resp = self._request(lambda: self._http.post(self._v2("/folders"), json=payload), f"create_folder({title!r})")
196
+ resp = http_request_with_retry(
197
+ lambda: self._http.post(self._v2("/folders"), json=payload),
198
+ f"create_folder({title!r})",
199
+ )
232
200
  if resp.status_code == 400:
233
201
  body = resp.text
234
202
  if "folder exists with the same title" in body.lower() or "same title" in body.lower():
@@ -278,7 +246,10 @@ class ConfluenceClient:
278
246
  if parent_id is not None:
279
247
  payload["parentId"] = parent_id
280
248
 
281
- resp = self._request(lambda: self._http.post(self._v2("/pages"), json=payload), f"create_page({title!r})")
249
+ resp = http_request_with_retry(
250
+ lambda: self._http.post(self._v2("/pages"), json=payload),
251
+ f"create_page({title!r})",
252
+ )
282
253
  self._raise_for_status(resp, f"create_page({title!r})")
283
254
  return resp.json() # type: ignore[no-any-return]
284
255
 
@@ -315,7 +286,7 @@ class ConfluenceClient:
315
286
  }
316
287
  if parent_id is not None:
317
288
  payload["parentId"] = parent_id
318
- resp = self._request(
289
+ resp = http_request_with_retry(
319
290
  lambda: self._http.put(self._v2(f"/pages/{page_id}"), json=payload),
320
291
  f"update_page({page_id!r})",
321
292
  )
@@ -335,7 +306,7 @@ class ConfluenceClient:
335
306
 
336
307
  if get_resp.status_code == 200:
337
308
  current_version = get_resp.json().get("version", {}).get("number", 1)
338
- self._request(
309
+ http_request_with_retry(
339
310
  lambda: self._http.put(
340
311
  prop_url,
341
312
  json={"key": key, "value": "full-width", "version": {"number": current_version + 1}},
@@ -343,7 +314,7 @@ class ConfluenceClient:
343
314
  "set_page_full_width",
344
315
  )
345
316
  else:
346
- self._request(
317
+ http_request_with_retry(
347
318
  lambda: self._http.post(
348
319
  self._v1(f"/content/{page_id}/property"),
349
320
  json={"key": key, "value": "full-width", "version": {"number": 1}},
@@ -376,7 +347,7 @@ class ConfluenceClient:
376
347
  get_resp = self._http.get(prop_url)
377
348
  if get_resp.status_code == 200:
378
349
  current_version = get_resp.json().get("version", {}).get("number", 1)
379
- self._request(
350
+ http_request_with_retry(
380
351
  lambda: self._http.put(
381
352
  prop_url,
382
353
  json={"key": key, "value": hash_str, "version": {"number": current_version + 1}},
@@ -384,7 +355,7 @@ class ConfluenceClient:
384
355
  "set_content_hash",
385
356
  )
386
357
  else:
387
- self._request(
358
+ http_request_with_retry(
388
359
  lambda: self._http.post(
389
360
  self._v1(f"/content/{page_id}/property"),
390
361
  json={"key": key, "value": hash_str, "version": {"number": 1}},
@@ -406,7 +377,7 @@ class ConfluenceClient:
406
377
  for lbl in existing_resp.json().get("results", []):
407
378
  name = lbl.get("name", "")
408
379
  if name:
409
- self._request(
380
+ http_request_with_retry(
410
381
  lambda: self._http.delete(label_url, params={"name": name}),
411
382
  f"set_page_labels({page_id!r})",
412
383
  )
@@ -414,7 +385,10 @@ class ConfluenceClient:
414
385
  # Apply new labels (if any)
415
386
  if labels:
416
387
  payload = [{"prefix": "global", "name": lbl} for lbl in labels]
417
- resp = self._request(lambda: self._http.post(label_url, json=payload), f"set_page_labels({page_id!r})")
388
+ resp = http_request_with_retry(
389
+ lambda: self._http.post(label_url, json=payload),
390
+ f"set_page_labels({page_id!r})",
391
+ )
418
392
  self._raise_for_status(resp, f"set_page_labels({page_id!r})")
419
393
 
420
394
  def set_page_status(self, page_id: str, status_key: str, space_key: str | None = None) -> None:
@@ -461,7 +435,7 @@ class ConfluenceClient:
461
435
 
462
436
  # `status` query param tells the API which page version to target (current vs draft).
463
437
  url = self._v1(f"/content/{page_id}/state")
464
- resp = self._request(
438
+ resp = http_request_with_retry(
465
439
  lambda: self._http.put(url, json=body, params={"status": "current"}),
466
440
  f"set_page_status({page_id!r}, {status_key!r})",
467
441
  )
@@ -533,7 +507,7 @@ class ConfluenceClient:
533
507
  else:
534
508
  url = self._v1(f"/content/{page_id}/child/attachment")
535
509
 
536
- resp = self._request(
510
+ resp = http_request_with_retry(
537
511
  lambda: self._http.post(
538
512
  url,
539
513
  files={"file": (filename, content)},
@@ -559,7 +533,7 @@ class ConfluenceClient:
559
533
  get_resp = self._http.get(url)
560
534
  if get_resp.status_code == 200:
561
535
  return # already stamped
562
- self._request(
536
+ http_request_with_retry(
563
537
  lambda: self._http.post(
564
538
  self._v2(f"/pages/{page_id}/properties"),
565
539
  json={"key": "mk2conf-managed", "value": True},
@@ -599,7 +573,10 @@ class ConfluenceClient:
599
573
 
600
574
  def delete_page(self, page_id: str) -> None:
601
575
  """Permanently delete *page_id* from Confluence."""
602
- resp = self._request(lambda: self._http.delete(self._v2(f"/pages/{page_id}")), f"delete_page({page_id!r})")
576
+ resp = http_request_with_retry(
577
+ lambda: self._http.delete(self._v2(f"/pages/{page_id}")),
578
+ f"delete_page({page_id!r})",
579
+ )
603
580
  self._raise_for_status(resp, f"delete_page({page_id!r})")
604
581
 
605
582
  # ── Comments ───────────────────────────────────────────────────────────────
@@ -641,7 +618,7 @@ class ConfluenceClient:
641
618
  def add_comment_reply(self, comment_id: str, reply_text: str) -> None:
642
619
  """Post a reply to *comment_id* using the v1 content API."""
643
620
  url = self._v1(f"/content/{comment_id}/child/comment")
644
- resp = self._request(lambda: self._http.post(url, json={
621
+ resp = http_request_with_retry(lambda: self._http.post(url, json={
645
622
  "type": "comment",
646
623
  "body": {
647
624
  "storage": {
@@ -660,7 +637,7 @@ class ConfluenceClient:
660
637
  data = get_resp.json()
661
638
  version = data.get("version", {}).get("number", 1)
662
639
  body_value = data.get("body", {}).get("storage", {}).get("value", "")
663
- resp = self._request(lambda: self._http.put(url, json={
640
+ resp = http_request_with_retry(lambda: self._http.put(url, json={
664
641
  "version": {"number": version + 1},
665
642
  "resolved": True,
666
643
  "body": {"representation": "storage", "value": body_value},
@@ -675,7 +652,7 @@ class ConfluenceClient:
675
652
  data = get_resp.json()
676
653
  version = data.get("version", {}).get("number", 1)
677
654
  body_value = data.get("body", {}).get("storage", {}).get("value", "")
678
- resp = self._request(lambda: self._http.put(url, json={
655
+ resp = http_request_with_retry(lambda: self._http.put(url, json={
679
656
  "version": {"number": version + 1},
680
657
  "resolved": True,
681
658
  "body": {"representation": "storage", "value": body_value},