mkdocs2confluence 0.9.4__tar.gz → 0.9.6__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 (87) hide show
  1. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/PKG-INFO +6 -1
  2. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/README.md +5 -0
  3. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/pyproject.toml +1 -1
  4. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs2confluence.egg-info/PKG-INFO +6 -1
  5. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/transforms/mermaid.py +58 -3
  6. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_mermaid.py +108 -4
  7. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/LICENSE +0 -0
  8. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/setup.cfg +0 -0
  9. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs2confluence.egg-info/SOURCES.txt +0 -0
  10. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs2confluence.egg-info/dependency_links.txt +0 -0
  11. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs2confluence.egg-info/entry_points.txt +0 -0
  12. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs2confluence.egg-info/requires.txt +0 -0
  13. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs2confluence.egg-info/top_level.txt +0 -0
  14. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/__init__.py +0 -0
  15. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/cli.py +0 -0
  16. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/emitter/__init__.py +0 -0
  17. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/emitter/xhtml.py +0 -0
  18. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/ir/__init__.py +0 -0
  19. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/ir/document.py +0 -0
  20. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/ir/nodes.py +0 -0
  21. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/ir/treeutil.py +0 -0
  22. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/loader/__init__.py +0 -0
  23. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/loader/config.py +0 -0
  24. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/loader/extra_css.py +0 -0
  25. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/loader/nav.py +0 -0
  26. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/loader/page.py +0 -0
  27. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/parser/__init__.py +0 -0
  28. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/parser/markdown.py +0 -0
  29. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/pdf/__init__.py +0 -0
  30. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/pdf/generator.py +0 -0
  31. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/pdf/render.py +0 -0
  32. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/preprocess/__init__.py +0 -0
  33. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/preprocess/abbrevs.py +0 -0
  34. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/preprocess/fence.py +0 -0
  35. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/preprocess/frontmatter.py +0 -0
  36. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/preprocess/icons.py +0 -0
  37. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/preprocess/includes.py +0 -0
  38. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/preprocess/linkdefs.py +0 -0
  39. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/preview/__init__.py +0 -0
  40. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/preview/render.py +0 -0
  41. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/preview/server.py +0 -0
  42. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/publisher/__init__.py +0 -0
  43. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/publisher/client.py +0 -0
  44. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/publisher/pipeline.py +0 -0
  45. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/sync/__init__.py +0 -0
  46. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/sync/anchoring.py +0 -0
  47. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/sync/command.py +0 -0
  48. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/sync/comments.py +0 -0
  49. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/sync/github.py +0 -0
  50. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/sync/platform.py +0 -0
  51. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/sync/state.py +0 -0
  52. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/transforms/__init__.py +0 -0
  53. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/transforms/abbrevs.py +0 -0
  54. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/transforms/assets.py +0 -0
  55. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/transforms/editlink.py +0 -0
  56. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/transforms/footer.py +0 -0
  57. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/transforms/images.py +0 -0
  58. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/src/mkdocs_to_confluence/transforms/internallinks.py +0 -0
  59. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_abbrevs.py +0 -0
  60. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_children_macro.py +0 -0
  61. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_cli.py +0 -0
  62. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_editlink.py +0 -0
  63. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_emitter.py +0 -0
  64. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_extra_css.py +0 -0
  65. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_footer.py +0 -0
  66. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_frontmatter.py +0 -0
  67. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_icons.py +0 -0
  68. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_images.py +0 -0
  69. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_internallinks.py +0 -0
  70. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_ir.py +0 -0
  71. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_linkdefs.py +0 -0
  72. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_loader.py +0 -0
  73. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_page_loader.py +0 -0
  74. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_parser.py +0 -0
  75. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_pdf.py +0 -0
  76. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_preprocess.py +0 -0
  77. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_preview.py +0 -0
  78. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_publish_client.py +0 -0
  79. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_publish_config.py +0 -0
  80. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_publish_pipeline.py +0 -0
  81. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_server.py +0 -0
  82. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_sync_anchoring.py +0 -0
  83. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_sync_command.py +0 -0
  84. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_sync_comments.py +0 -0
  85. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_sync_github.py +0 -0
  86. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_sync_state.py +0 -0
  87. {mkdocs2confluence-0.9.4 → mkdocs2confluence-0.9.6}/tests/test_treeutil.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.9.4
3
+ Version: 0.9.6
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
@@ -245,6 +245,8 @@ mk2conf publish [--config PATH] [--page PATH] [--section SECTION] [--dry-run] [-
245
245
 
246
246
  If Kroki is unreachable the run continues, falling back to the `code` macro for affected diagrams.
247
247
 
248
+ **Automatic mermaid.ink fallback:** when using the public `kroki.io` service and a Mermaid diagram receives a 504 (gateway timeout), mk2conf automatically retries that diagram via [mermaid.ink](https://mermaid.ink) before giving up. No configuration needed — the fallback is transparent and only applies to Mermaid diagrams on the public service. Self-hosted Kroki instances never contact mermaid.ink, preserving network isolation.
249
+
248
250
  #### Styling from extra.css
249
251
 
250
252
  If `mkdocs.yml` lists `extra_css:` files, mk2conf reads them and applies a whitelisted set of CSS properties as inline `style="..."` attributes on Confluence output.
@@ -368,6 +370,7 @@ confluence:
368
370
  | `^^inserted^^` | `<u>` (pymdownx.caret insert) |
369
371
  | `` `inline code` `` | `<code>` |
370
372
  | `[text](url)` | `<a href="...">` |
373
+ | `[text][label]` / `[text][]` with `[label]: url` | Resolved to inline link before parsing |
371
374
  | `https://bare-url` | `<a href="...">` (autolink) |
372
375
  | `[text](file.pdf)` | `<ac:link><ri:attachment .../>` (uploaded as attachment) |
373
376
  | `![alt](src)` | `<ac:image>` with `<ri:attachment>` (local) or `<ri:url>` (remote) |
@@ -433,6 +436,8 @@ When `repo_url` + `edit_uri` are set in `mkdocs.yml`, a **Page source** footer p
433
436
  - **View history** — links to the file's commit history (derived automatically for GitHub and GitLab URLs)
434
437
  - **Last commit** — short commit SHA (linked to that commit), message, author, and relative date from `git log` at publish time (omitted if git is unavailable or the file is untracked)
435
438
 
439
+ The commit SHA and message are also written to the **Confluence version history** on every publish (`sha: message`), so the page history in Confluence stays in sync with your git log.
440
+
436
441
  ### Section index child pages
437
442
 
438
443
  When a MkDocs navigation section has an `index.md`, the published Confluence page automatically includes the native **Children Display macro** below the page content. This renders a live, auto-maintained list of all direct child pages — no manual curation needed. The macro is injected by default on every section index page.
@@ -205,6 +205,8 @@ mk2conf publish [--config PATH] [--page PATH] [--section SECTION] [--dry-run] [-
205
205
 
206
206
  If Kroki is unreachable the run continues, falling back to the `code` macro for affected diagrams.
207
207
 
208
+ **Automatic mermaid.ink fallback:** when using the public `kroki.io` service and a Mermaid diagram receives a 504 (gateway timeout), mk2conf automatically retries that diagram via [mermaid.ink](https://mermaid.ink) before giving up. No configuration needed — the fallback is transparent and only applies to Mermaid diagrams on the public service. Self-hosted Kroki instances never contact mermaid.ink, preserving network isolation.
209
+
208
210
  #### Styling from extra.css
209
211
 
210
212
  If `mkdocs.yml` lists `extra_css:` files, mk2conf reads them and applies a whitelisted set of CSS properties as inline `style="..."` attributes on Confluence output.
@@ -328,6 +330,7 @@ confluence:
328
330
  | `^^inserted^^` | `<u>` (pymdownx.caret insert) |
329
331
  | `` `inline code` `` | `<code>` |
330
332
  | `[text](url)` | `<a href="...">` |
333
+ | `[text][label]` / `[text][]` with `[label]: url` | Resolved to inline link before parsing |
331
334
  | `https://bare-url` | `<a href="...">` (autolink) |
332
335
  | `[text](file.pdf)` | `<ac:link><ri:attachment .../>` (uploaded as attachment) |
333
336
  | `![alt](src)` | `<ac:image>` with `<ri:attachment>` (local) or `<ri:url>` (remote) |
@@ -393,6 +396,8 @@ When `repo_url` + `edit_uri` are set in `mkdocs.yml`, a **Page source** footer p
393
396
  - **View history** — links to the file's commit history (derived automatically for GitHub and GitLab URLs)
394
397
  - **Last commit** — short commit SHA (linked to that commit), message, author, and relative date from `git log` at publish time (omitted if git is unavailable or the file is untracked)
395
398
 
399
+ The commit SHA and message are also written to the **Confluence version history** on every publish (`sha: message`), so the page history in Confluence stays in sync with your git log.
400
+
396
401
  ### Section index child pages
397
402
 
398
403
  When a MkDocs navigation section has an `index.md`, the published Confluence page automatically includes the native **Children Display macro** below the page content. This renders a live, auto-maintained list of all direct child pages — no manual curation needed. The macro is injected by default on every section index page.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mkdocs2confluence"
3
- version = "0.9.4"
3
+ version = "0.9.6"
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.9.4
3
+ Version: 0.9.6
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
@@ -245,6 +245,8 @@ mk2conf publish [--config PATH] [--page PATH] [--section SECTION] [--dry-run] [-
245
245
 
246
246
  If Kroki is unreachable the run continues, falling back to the `code` macro for affected diagrams.
247
247
 
248
+ **Automatic mermaid.ink fallback:** when using the public `kroki.io` service and a Mermaid diagram receives a 504 (gateway timeout), mk2conf automatically retries that diagram via [mermaid.ink](https://mermaid.ink) before giving up. No configuration needed — the fallback is transparent and only applies to Mermaid diagrams on the public service. Self-hosted Kroki instances never contact mermaid.ink, preserving network isolation.
249
+
248
250
  #### Styling from extra.css
249
251
 
250
252
  If `mkdocs.yml` lists `extra_css:` files, mk2conf reads them and applies a whitelisted set of CSS properties as inline `style="..."` attributes on Confluence output.
@@ -368,6 +370,7 @@ confluence:
368
370
  | `^^inserted^^` | `<u>` (pymdownx.caret insert) |
369
371
  | `` `inline code` `` | `<code>` |
370
372
  | `[text](url)` | `<a href="...">` |
373
+ | `[text][label]` / `[text][]` with `[label]: url` | Resolved to inline link before parsing |
371
374
  | `https://bare-url` | `<a href="...">` (autolink) |
372
375
  | `[text](file.pdf)` | `<ac:link><ri:attachment .../>` (uploaded as attachment) |
373
376
  | `![alt](src)` | `<ac:image>` with `<ri:attachment>` (local) or `<ri:url>` (remote) |
@@ -433,6 +436,8 @@ When `repo_url` + `edit_uri` are set in `mkdocs.yml`, a **Page source** footer p
433
436
  - **View history** — links to the file's commit history (derived automatically for GitHub and GitLab URLs)
434
437
  - **Last commit** — short commit SHA (linked to that commit), message, author, and relative date from `git log` at publish time (omitted if git is unavailable or the file is untracked)
435
438
 
439
+ The commit SHA and message are also written to the **Confluence version history** on every publish (`sha: message`), so the page history in Confluence stays in sync with your git log.
440
+
436
441
  ### Section index child pages
437
442
 
438
443
  When a MkDocs navigation section has an `index.md`, the published Confluence page automatically includes the native **Children Display macro** below the page content. This renders a live, auto-maintained list of all direct child pages — no manual curation needed. The macro is injected by default on every section index page.
@@ -10,17 +10,28 @@ of the Mermaid source so unchanged diagrams are never re-fetched.
10
10
  When Kroki is unavailable (network error, HTTP error, timeout, or bad response)
11
11
  each affected diagram is left unchanged so the emitter falls back to a fenced
12
12
  code block. The rest of the pipeline continues unaffected.
13
+
14
+ Fallback behaviour
15
+ ------------------
16
+ When the public ``https://kroki.io`` service returns a 504 (gateway timeout)
17
+ for a Mermaid diagram, mk2conf automatically retries via ``mermaid.ink`` before
18
+ giving up. This fallback does **not** apply to self-hosted Kroki instances
19
+ (configured as ``kroki:<url>``) — those run in isolation and should not reach
20
+ out to external services.
13
21
  """
14
22
 
15
23
  from __future__ import annotations
16
24
 
25
+ import base64
17
26
  import dataclasses
18
27
  import hashlib
28
+ import json
19
29
  import sys
20
30
  import threading
21
31
  import time
22
32
  import urllib.error
23
33
  import urllib.request
34
+ import zlib
24
35
  from concurrent.futures import ThreadPoolExecutor, as_completed
25
36
  from pathlib import Path
26
37
  from typing import cast
@@ -30,7 +41,8 @@ from mkdocs_to_confluence.ir.treeutil import replace_nodes
30
41
 
31
42
  _CACHE_DIR = Path.home() / ".cache" / "mk2conf" / "mermaid"
32
43
  DEFAULT_KROKI_URL = "https://kroki.io"
33
- _TIMEOUT = 30 # seconds — fail fast when Kroki is down
44
+ _MERMAID_INK_URL = "https://mermaid.ink"
45
+ _TIMEOUT = 30 # seconds
34
46
  _MIN_PNG_BYTES = 67 # smallest valid PNG (1×1 px) is 67 bytes
35
47
  _CACHE_LOCK = threading.Lock()
36
48
  _MAX_WORKERS = 8
@@ -57,6 +69,21 @@ def _kroki_png(source: str, kroki_url: str) -> bytes:
57
69
  return cast(bytes, resp.read())
58
70
 
59
71
 
72
+ def _mermaid_ink_png(source: str) -> bytes:
73
+ """Fetch a PNG rendering of *source* from mermaid.ink (GET, pako-encoded).
74
+
75
+ Encodes the diagram as ``{"code": source}`` compressed with zlib and
76
+ base64url-encoded — the pako format that mermaid.ink expects.
77
+ """
78
+ payload = json.dumps({"code": source, "mermaid": {"theme": "default"}}, separators=(",", ":"))
79
+ compressed = zlib.compress(payload.encode("utf-8"))
80
+ encoded = base64.urlsafe_b64encode(compressed).decode().rstrip("=")
81
+ url = f"{_MERMAID_INK_URL}/img/pako:{encoded}?type=png"
82
+ req = urllib.request.Request(url, headers={"User-Agent": "mk2conf/1.0"})
83
+ with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp: # noqa: S310 # nosec B310
84
+ return cast(bytes, resp.read())
85
+
86
+
60
87
  def _cache_path(source: str) -> Path:
61
88
  digest = hashlib.sha256(source.encode()).hexdigest()
62
89
  return _CACHE_DIR / f"mermaid_{digest}.png"
@@ -71,6 +98,11 @@ def _render_one(source: str, kroki_url: str, *, quiet: bool = False) -> Path | N
71
98
 
72
99
  Transient HTTP errors (429, 5xx) and network blips are retried up to
73
100
  ``_RETRY_ATTEMPTS`` times with exponential backoff.
101
+
102
+ When using the public kroki.io and all retries are exhausted due to kroki
103
+ being unreachable (504 or timeout), one final attempt is made via
104
+ mermaid.ink before giving up. Self-hosted Kroki instances never fall back
105
+ to mermaid.ink.
74
106
  """
75
107
  path = _cache_path(source)
76
108
  if path.exists():
@@ -78,7 +110,10 @@ def _render_one(source: str, kroki_url: str, *, quiet: bool = False) -> Path | N
78
110
  print(" rendering mermaid diagram (cached)")
79
111
  return path
80
112
 
113
+ use_public_kroki = kroki_url.rstrip("/") == DEFAULT_KROKI_URL.rstrip("/")
81
114
  last_exc: Exception | None = None
115
+ kroki_unavailable = False # True when kroki is unreachable (504 or timeout)
116
+
82
117
  for attempt in range(_RETRY_ATTEMPTS):
83
118
  if attempt > 0:
84
119
  delay = _RETRY_BACKOFF * (2 ** (attempt - 1))
@@ -96,17 +131,34 @@ def _render_one(source: str, kroki_url: str, *, quiet: bool = False) -> Path | N
96
131
  except urllib.error.HTTPError as exc:
97
132
  if exc.code in _RETRYABLE_HTTP:
98
133
  last_exc = exc
134
+ kroki_unavailable = exc.code == 504
99
135
  continue # retry
100
136
  _warn(f"mermaid diagram: Kroki returned HTTP {exc.code} {exc.reason} — falling back to code block")
101
137
  return None
102
138
  except urllib.error.URLError as exc:
103
139
  last_exc = exc
140
+ kroki_unavailable = True # timeout or connection refused — kroki unreachable
104
141
  continue # retry — network blip
105
142
  except (OSError, ValueError) as exc:
106
143
  _warn(f"mermaid diagram: {exc} — falling back to code block")
107
144
  return None
108
145
 
109
- # All retries exhausted
146
+ # All Kroki retries exhausted — try mermaid.ink if on public kroki.io and kroki was unreachable.
147
+ if use_public_kroki and kroki_unavailable:
148
+ try:
149
+ _warn("mermaid diagram: kroki.io unreachable, trying mermaid.ink as fallback")
150
+ png = _mermaid_ink_png(source)
151
+ if len(png) < _MIN_PNG_BYTES:
152
+ raise ValueError(f"mermaid.ink returned {len(png)} bytes (expected a valid PNG)")
153
+ with _CACHE_LOCK:
154
+ path.write_bytes(png)
155
+ if not quiet:
156
+ print(" rendering mermaid diagram (via mermaid.ink fallback)")
157
+ return path
158
+ except (urllib.error.URLError, urllib.error.HTTPError, OSError, ValueError) as exc:
159
+ _warn(f"mermaid diagram: mermaid.ink fallback also failed ({exc}) — falling back to code block")
160
+ return None
161
+
110
162
  _warn(f"mermaid diagram: failed after {_RETRY_ATTEMPTS} attempts ({last_exc}) — falling back to code block")
111
163
  return None
112
164
 
@@ -125,7 +177,10 @@ def render_mermaid_diagrams(
125
177
  upload as page attachments.
126
178
 
127
179
  Diagrams that fail to render are left unchanged (code-block fallback).
128
- The pipeline always produces valid output regardless of Kroki availability.
180
+ When using public kroki.io and a diagram fails with a 504, one automatic
181
+ retry via mermaid.ink is attempted before falling back to a code block.
182
+ Self-hosted Kroki instances never contact mermaid.ink.
183
+ The pipeline always produces valid output regardless of service availability.
129
184
  """
130
185
  try:
131
186
  _CACHE_DIR.mkdir(parents=True, exist_ok=True)
@@ -102,7 +102,7 @@ def test_render_deduplicates_identical_diagrams(tmp_path):
102
102
 
103
103
 
104
104
  def test_render_fallback_on_network_error(tmp_path, capsys):
105
- """When Kroki is unreachable, node is left unchanged (code block fallback)."""
105
+ """When Kroki is unreachable and mermaid.ink also fails, node falls back to code block."""
106
106
  import urllib.error
107
107
 
108
108
  node = MermaidDiagram(source=_SAMPLE_SOURCE)
@@ -114,6 +114,10 @@ def test_render_fallback_on_network_error(tmp_path, capsys):
114
114
  "mkdocs_to_confluence.transforms.mermaid._kroki_png",
115
115
  side_effect=urllib.error.URLError("timed out"),
116
116
  ),
117
+ patch(
118
+ "mkdocs_to_confluence.transforms.mermaid._mermaid_ink_png",
119
+ side_effect=urllib.error.URLError("timed out"),
120
+ ),
117
121
  ):
118
122
  updated_nodes, attachments = render_mermaid_diagrams((node,))
119
123
 
@@ -218,7 +222,7 @@ def test_render_multiple_diagrams_concurrently(tmp_path):
218
222
 
219
223
 
220
224
  def test_render_one_failure_does_not_block_others(tmp_path):
221
- """If one diagram fails, the rest still render successfully."""
225
+ """If one diagram fails (both kroki and mermaid.ink), the rest still render successfully."""
222
226
  import urllib.error
223
227
 
224
228
  good_source = "graph TD\n A --> B\n"
@@ -231,7 +235,9 @@ def test_render_one_failure_does_not_block_others(tmp_path):
231
235
  return _FAKE_PNG
232
236
 
233
237
  with patch("mkdocs_to_confluence.transforms.mermaid._CACHE_DIR", tmp_path), \
234
- patch("mkdocs_to_confluence.transforms.mermaid._kroki_png", side_effect=fake_kroki):
238
+ patch("mkdocs_to_confluence.transforms.mermaid._kroki_png", side_effect=fake_kroki), \
239
+ patch("mkdocs_to_confluence.transforms.mermaid._mermaid_ink_png",
240
+ side_effect=urllib.error.URLError("timeout")):
235
241
  updated, attachments = render_mermaid_diagrams(nodes)
236
242
 
237
243
  # One attachment for the successful diagram
@@ -258,7 +264,7 @@ def test_render_one_cached(tmp_path):
258
264
 
259
265
 
260
266
  def test_render_one_network_failure_returns_none(tmp_path):
261
- """_render_one returns None after all retries on persistent network failure."""
267
+ """_render_one returns None after all retries when both kroki and mermaid.ink fail."""
262
268
  import urllib.error
263
269
 
264
270
  from mkdocs_to_confluence.transforms.mermaid import _render_one
@@ -266,6 +272,8 @@ def test_render_one_network_failure_returns_none(tmp_path):
266
272
  with patch("mkdocs_to_confluence.transforms.mermaid._CACHE_DIR", tmp_path), \
267
273
  patch("mkdocs_to_confluence.transforms.mermaid.time.sleep"), \
268
274
  patch("mkdocs_to_confluence.transforms.mermaid._kroki_png",
275
+ side_effect=urllib.error.URLError("connection refused")), \
276
+ patch("mkdocs_to_confluence.transforms.mermaid._mermaid_ink_png",
269
277
  side_effect=urllib.error.URLError("connection refused")):
270
278
  result = _render_one(_SAMPLE_SOURCE, "https://kroki.io")
271
279
 
@@ -365,3 +373,99 @@ def test_render_mermaid_cached_quiet_suppresses_stdout(tmp_path, capsys):
365
373
  assert result is not None
366
374
  out, _ = capsys.readouterr()
367
375
  assert out == ""
376
+
377
+
378
+ # ── mermaid.ink fallback ──────────────────────────────────────────────────────
379
+
380
+
381
+ def test_render_one_504_on_public_kroki_falls_back_to_mermaid_ink(tmp_path, capsys):
382
+ """Public kroki.io 504 → automatic fallback to mermaid.ink."""
383
+ import urllib.error
384
+
385
+ from mkdocs_to_confluence.transforms.mermaid import _render_one
386
+
387
+ with patch("mkdocs_to_confluence.transforms.mermaid._CACHE_DIR", tmp_path), \
388
+ patch("mkdocs_to_confluence.transforms.mermaid.time.sleep"), \
389
+ patch("mkdocs_to_confluence.transforms.mermaid._kroki_png",
390
+ side_effect=urllib.error.HTTPError("url", 504, "Gateway Timeout", {}, None)), \
391
+ patch("mkdocs_to_confluence.transforms.mermaid._mermaid_ink_png",
392
+ return_value=_FAKE_PNG) as mock_ink:
393
+ result = _render_one(_SAMPLE_SOURCE, "https://kroki.io")
394
+
395
+ assert result is not None
396
+ assert result.exists()
397
+ mock_ink.assert_called_once_with(_SAMPLE_SOURCE)
398
+
399
+
400
+ def test_render_one_timeout_on_public_kroki_falls_back_to_mermaid_ink(tmp_path):
401
+ """Public kroki.io timeout → automatic fallback to mermaid.ink."""
402
+ import urllib.error
403
+
404
+ from mkdocs_to_confluence.transforms.mermaid import _render_one
405
+
406
+ with patch("mkdocs_to_confluence.transforms.mermaid._CACHE_DIR", tmp_path), \
407
+ patch("mkdocs_to_confluence.transforms.mermaid.time.sleep"), \
408
+ patch("mkdocs_to_confluence.transforms.mermaid._kroki_png",
409
+ side_effect=urllib.error.URLError("timed out")), \
410
+ patch("mkdocs_to_confluence.transforms.mermaid._mermaid_ink_png",
411
+ return_value=_FAKE_PNG) as mock_ink:
412
+ result = _render_one(_SAMPLE_SOURCE, "https://kroki.io")
413
+
414
+ assert result is not None
415
+ assert result.exists()
416
+ mock_ink.assert_called_once_with(_SAMPLE_SOURCE)
417
+
418
+
419
+ def test_render_one_504_on_self_hosted_kroki_does_not_fall_back(tmp_path):
420
+ """Self-hosted Kroki 504 → no mermaid.ink fallback (privacy isolation)."""
421
+ import urllib.error
422
+
423
+ from mkdocs_to_confluence.transforms.mermaid import _render_one
424
+
425
+ with patch("mkdocs_to_confluence.transforms.mermaid._CACHE_DIR", tmp_path), \
426
+ patch("mkdocs_to_confluence.transforms.mermaid.time.sleep"), \
427
+ patch("mkdocs_to_confluence.transforms.mermaid._kroki_png",
428
+ side_effect=urllib.error.HTTPError("url", 504, "Gateway Timeout", {}, None)), \
429
+ patch("mkdocs_to_confluence.transforms.mermaid._mermaid_ink_png",
430
+ return_value=_FAKE_PNG) as mock_ink:
431
+ result = _render_one(_SAMPLE_SOURCE, "https://my-internal-kroki.corp")
432
+
433
+ assert result is None
434
+ mock_ink.assert_not_called()
435
+
436
+
437
+ def test_render_one_timeout_on_self_hosted_kroki_does_not_fall_back(tmp_path):
438
+ """Self-hosted Kroki timeout → no mermaid.ink fallback (privacy isolation)."""
439
+ import urllib.error
440
+
441
+ from mkdocs_to_confluence.transforms.mermaid import _render_one
442
+
443
+ with patch("mkdocs_to_confluence.transforms.mermaid._CACHE_DIR", tmp_path), \
444
+ patch("mkdocs_to_confluence.transforms.mermaid.time.sleep"), \
445
+ patch("mkdocs_to_confluence.transforms.mermaid._kroki_png",
446
+ side_effect=urllib.error.URLError("timed out")), \
447
+ patch("mkdocs_to_confluence.transforms.mermaid._mermaid_ink_png",
448
+ return_value=_FAKE_PNG) as mock_ink:
449
+ result = _render_one(_SAMPLE_SOURCE, "https://my-internal-kroki.corp")
450
+
451
+ assert result is None
452
+ mock_ink.assert_not_called()
453
+
454
+
455
+ def test_render_one_mermaid_ink_fallback_also_fails_returns_none(tmp_path, capsys):
456
+ """When mermaid.ink fallback also fails, returns None (code-block fallback)."""
457
+ import urllib.error
458
+
459
+ from mkdocs_to_confluence.transforms.mermaid import _render_one
460
+
461
+ with patch("mkdocs_to_confluence.transforms.mermaid._CACHE_DIR", tmp_path), \
462
+ patch("mkdocs_to_confluence.transforms.mermaid.time.sleep"), \
463
+ patch("mkdocs_to_confluence.transforms.mermaid._kroki_png",
464
+ side_effect=urllib.error.HTTPError("url", 504, "Gateway Timeout", {}, None)), \
465
+ patch("mkdocs_to_confluence.transforms.mermaid._mermaid_ink_png",
466
+ side_effect=urllib.error.URLError("connection refused")):
467
+ result = _render_one(_SAMPLE_SOURCE, "https://kroki.io")
468
+
469
+ assert result is None
470
+ _, err = capsys.readouterr()
471
+ assert "mermaid.ink" in err