Skip to content

Commit 8339ca4

Browse files
authored
Merge pull request #22 from kellenmurphy/add-mermaid-support
feat: Mermaid diagram rendering via Playwright
2 parents bb778af + 06a226f commit 8339ca4

6 files changed

Lines changed: 410 additions & 21 deletions

File tree

Dockerfile

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,31 @@
1-
FROM dhi.io/python:3.13-debian13-dev AS builder
1+
FROM dhi.io/python:3.13-debian13-dev
22
WORKDIR /app
3+
34
COPY requirements.txt .
45
RUN pip install --no-cache-dir -r requirements.txt
56

6-
FROM dhi.io/python:3.13
7-
WORKDIR /app
8-
COPY --from=builder /opt/python/lib/python3.13/site-packages /opt/python/lib/python3.13/site-packages
7+
# Download Playwright's Chromium browser binary into a world-readable path.
8+
# --with-deps is intentionally omitted: it pulls in Xvfb and X11 server packages
9+
# that fail to configure in this apt environment. Chromium in headless mode does
10+
# not need a display server; the required shared libraries are installed below.
11+
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers
12+
RUN playwright install chromium
13+
14+
# Install Chromium's runtime library dependencies and bundle mermaid.min.js
15+
# at build time so diagram rendering works fully offline at runtime.
16+
# Node.js is only needed here to npm-install mermaid and extract its dist file.
17+
RUN apt-get update && apt-get install -y --no-install-recommends \
18+
libasound2t64 libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0t64 \
19+
libcairo2 libcups2t64 libdbus-1-3 libdrm2 libgbm1 libglib2.0-0t64 \
20+
libnspr4 libnss3 libpango-1.0-0 \
21+
libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 \
22+
libxi6 libxkbcommon0 libxrandr2 libxrender1 \
23+
fonts-liberation fonts-noto-color-emoji \
24+
nodejs npm \
25+
&& npm install --prefix /tmp/mermaid mermaid \
26+
&& cp /tmp/mermaid/node_modules/mermaid/dist/mermaid.min.js /app/mermaid.min.js \
27+
&& rm -rf /tmp/mermaid /var/lib/apt/lists/* \
28+
&& chmod -R a+rX /opt/playwright-browsers
29+
930
COPY py_conf_sync.py .
1031
ENTRYPOINT ["python", "/app/py_conf_sync.py"]

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ Confluence will render correctly, and pulling that page back produces the same M
206206
| Collapsible expand sections | `> [!EXPAND] Title` blockquote | `ac:structured-macro ac:name="expand"` |
207207
| Tables | GFM pipe tables | `<table class="wrapped">` |
208208
| Ordered and unordered lists (nested) | `1.` / `-` with 2-space indent | `<ol>` / `<ul>` |
209+
| Mermaid diagrams | ` ```mermaid ``` ` | PNG attachment + `.txt` source attachment; fence restored on pull |
209210
| Attached images (with size, alignment, caption) | `![filename](img/filename "ac:width=750 ac:align=center ac:title=...")` | `ac:image` + `ri:attachment` with all display attributes restored |
210211
| External images | `![alt](https://...)` | `ac:image` + `ri:url` |
211212
| Info / Note / Warning / Tip panels | `> [!NOTE]` / `> [!INFO]` / `> [!WARNING]` / `> [!TIP]` | `ac:structured-macro ac:name="note\|info\|warning\|tip"` |
@@ -215,6 +216,43 @@ Confluence will render correctly, and pulling that page back produces the same M
215216
| Blockquotes | `>` | `<blockquote>` |
216217
| Horizontal rules | `---` | `<hr />` |
217218

219+
### Mermaid diagrams
220+
221+
Fenced ` ```mermaid ` blocks are automatically rendered to PNG images and uploaded as
222+
Confluence attachments on push. Confluence Data Center does not natively render Mermaid,
223+
so this gives you diagrams in git-tracked Markdown that display correctly on the published
224+
page — without any Confluence app or plugin.
225+
226+
**On push:** each ` ```mermaid ` block is rendered to a retina-quality PNG by headless
227+
Chromium (bundled in the Docker image via Playwright and a bundled `mermaid.min.js` — no
228+
network calls at render time). The PNG is uploaded as a page attachment and replaces the
229+
mermaid block in the Confluence page body. A **Mermaid source** link to a companion `.txt`
230+
attachment is placed below each diagram so the original source is accessible to Confluence
231+
readers and editors.
232+
233+
**On pull:** the `.txt` attachment is fetched from Confluence and the original
234+
` ```mermaid ` fence is restored in the local Markdown file. No source is lost across the
235+
round-trip.
236+
237+
If a mermaid block fails to render (syntax error, Playwright unavailable), the push aborts
238+
for that page.
239+
240+
```markdown
241+
## Architecture
242+
243+
```mermaid
244+
flowchart LR
245+
A[Web App] --> B[Auth Service]
246+
B --> C[(Database)]
247+
```
248+
```
249+
250+
After push, Confluence shows the rendered diagram with a Mermaid source link below it.
251+
After pull, the ` ```mermaid ` block is restored in the local Markdown file.
252+
253+
> **Running outside Docker:** `playwright` must be installed and `mermaid.min.js` must be
254+
> present alongside `py_conf_sync.py`. The Docker image handles both automatically.
255+
218256
### Local image attachments
219257

220258
If your repo has an `img/` directory containing attachment images, py-conf-sync

py_conf_sync.py

Lines changed: 189 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@
1111
"""
1212

1313
import argparse
14+
import hashlib
1415
import html as html_lib
1516
import json
1617
import os
1718
import re
1819
import sys
20+
import tempfile
1921
from pathlib import Path
2022
from urllib.parse import quote, unquote
2123

@@ -395,13 +397,23 @@ def markdown_to_storage(markdown_text: str, base_url: str | None = None, page_id
395397
sys.exit(1)
396398

397399
extensions = ["tables", "fenced_code", "attr_list", "nl2br"]
398-
# The Python markdown library does not support CommonMark trailing-backslash
399-
# hard line breaks — strip them so they don't appear as literal \ in Confluence.
400-
# Lines where \ is the only non-whitespace content (e.g. " \") must become
401-
# truly empty lines, not spaces-only lines; otherwise the markdown library
402-
# mistakes the next indented line for a code block inside a list.
403-
markdown_text = re.sub(r'^[ \t]+\\[ \t]*\n', '\n', markdown_text, flags=re.MULTILINE)
404-
markdown_text = re.sub(r'\\[ \t]*\n', '\n', markdown_text)
400+
# Strip CommonMark trailing-backslash hard line breaks from prose only.
401+
# Applying the regexes to the whole document also strips backslash line
402+
# continuations inside fenced code blocks (e.g. Dockerfile RUN commands).
403+
# Split on fence boundaries so only prose segments are affected.
404+
_fence_parts = re.split(
405+
r'(^(?:```|~~~)[^\n]*\n.*?^(?:```|~~~)[ \t]*$)',
406+
markdown_text, flags=re.MULTILINE | re.DOTALL,
407+
)
408+
for _i in range(0, len(_fence_parts), 2): # even indices are prose
409+
_p = _fence_parts[_i]
410+
# Lines where \ is the only non-whitespace content (e.g. " \") must
411+
# become truly empty lines so the markdown library doesn't mistake the
412+
# next indented line for a code block continuation inside a list.
413+
_p = re.sub(r'^[ \t]+\\[ \t]*\n', '\n', _p, flags=re.MULTILINE)
414+
_p = re.sub(r'\\[ \t]*\n', '\n', _p)
415+
_fence_parts[_i] = _p
416+
markdown_text = ''.join(_fence_parts)
405417
# For two-digit (and higher) numbered list items, the required continuation
406418
# indentation (4+ spaces) equals the 4-space code block threshold. A blank
407419
# line before a 4+-space-indented image breaks the list out of <ol> context
@@ -693,6 +705,16 @@ def cmd_pull(args):
693705
page_id=page_id,
694706
img_dir=config.get("img_dir", "img"),
695707
)
708+
source_map: dict = {}
709+
for src_m in _MERMAID_SOURCE_URL_RE.finditer(markdown_text):
710+
url, digest = src_m.group(1), src_m.group(2)
711+
try:
712+
resp = client.session.get(url)
713+
resp.raise_for_status()
714+
source_map[digest] = resp.text.strip()
715+
except Exception:
716+
pass
717+
markdown_text = _restore_mermaid_blocks(markdown_text, source_map)
696718

697719
fm_data = {
698720
"confluence_page_id": str(page_id),
@@ -716,7 +738,143 @@ def cmd_pull(args):
716738
save_config(config, args._config_path)
717739

718740

741+
_MERMAID_FENCE_RE = re.compile(r'```mermaid\n(.*?)\n```', re.DOTALL)
742+
_MERMAID_JS_PATH = Path(__file__).parent / "mermaid.min.js"
743+
744+
745+
def _render_mermaid_blocks(body: str, tmp_dir: Path, dry_run: bool = False) -> str:
746+
"""Replace ```mermaid fenced blocks with PNG image references rendered via Playwright."""
747+
if not _MERMAID_FENCE_RE.search(body):
748+
return body
749+
750+
if dry_run:
751+
def _replace_dry(m):
752+
digest = hashlib.sha256(m.group(1).encode()).hexdigest()[:12]
753+
print(f"\n [dry-run] would render mermaid block → mermaid-{digest}.png / .txt")
754+
return m.group(0)
755+
return _MERMAID_FENCE_RE.sub(_replace_dry, body)
756+
757+
if not _MERMAID_JS_PATH.exists():
758+
raise RuntimeError(f"mermaid.min.js not found at {_MERMAID_JS_PATH} — rebuild the image")
759+
760+
try:
761+
from playwright.sync_api import sync_playwright
762+
except ImportError:
763+
raise RuntimeError("playwright not installed — rebuild the image")
764+
765+
mermaid_js = _MERMAID_JS_PATH.read_text(encoding="utf-8")
766+
counter = [0]
767+
768+
with sync_playwright() as pw:
769+
browser = pw.chromium.launch(args=[
770+
"--no-sandbox",
771+
"--disable-setuid-sandbox",
772+
"--disable-dev-shm-usage",
773+
"--disable-gpu",
774+
])
775+
# device_scale_factor=2 gives retina-quality PNGs; the large viewport
776+
# ensures mermaid has room to lay out wide flowcharts before we fix the
777+
# SVG dimensions explicitly.
778+
context = browser.new_context(
779+
viewport={"width": 3200, "height": 2400},
780+
device_scale_factor=2.0,
781+
)
782+
783+
def _replace(m):
784+
source = m.group(1)
785+
digest = hashlib.sha256(source.encode()).hexdigest()[:12]
786+
output_file = tmp_dir / f"mermaid-{digest}.png"
787+
(tmp_dir / f"mermaid-{digest}.txt").write_text(source, encoding="utf-8")
788+
escaped = html_lib.escape(source, quote=False)
789+
page_html = (
790+
"<!DOCTYPE html><html>"
791+
"<head><style>"
792+
"body{margin:0;background:white}"
793+
"#diagram{display:inline-block;padding:16px;background:white}"
794+
"</style></head><body>"
795+
f'<div id="diagram" class="mermaid">{escaped}</div>'
796+
f"<script>{mermaid_js}</script>"
797+
"<script>"
798+
"mermaid.initialize({startOnLoad:false,theme:'default'});"
799+
"mermaid.run({nodes:[document.getElementById('diagram')]}).then(function(){"
800+
# After render, fix SVG to explicit px dimensions from its viewBox
801+
# so it renders at natural size instead of 100%-of-container.
802+
"var svg=document.querySelector('#diagram svg');"
803+
"if(svg){var vb=svg.viewBox.baseVal;"
804+
"if(vb&&vb.width>0){svg.setAttribute('width',vb.width+'px');svg.setAttribute('height',vb.height+'px');}}"
805+
"document.title='ready';"
806+
"}).catch(function(e){"
807+
"document.title='error:'+e.message;"
808+
"});"
809+
"</script></body></html>"
810+
)
811+
try:
812+
pg = context.new_page()
813+
try:
814+
pg.set_content(page_html, wait_until="domcontentloaded")
815+
pg.wait_for_function(
816+
"document.title === 'ready' || document.title.startsWith('error:')",
817+
timeout=30_000,
818+
)
819+
title = pg.title()
820+
if title.startswith("error:"):
821+
raise RuntimeError(f"Mermaid render error: {title[6:]}")
822+
el = pg.query_selector("#diagram")
823+
if not el:
824+
raise RuntimeError("Mermaid produced no output element")
825+
el.screenshot(path=str(output_file))
826+
finally:
827+
pg.close()
828+
except RuntimeError:
829+
raise
830+
except Exception as e:
831+
raise RuntimeError(f"Playwright render failed: {e}") from e
832+
833+
counter[0] += 1
834+
txt_file = tmp_dir / f"mermaid-{digest}.txt"
835+
return f"![Diagram {counter[0]}]({output_file})\n\n[Mermaid source]({txt_file})"
836+
837+
try:
838+
result = _MERMAID_FENCE_RE.sub(_replace, body)
839+
finally:
840+
context.close()
841+
browser.close()
842+
843+
return result
844+
845+
846+
_MERMAID_SOURCE_URL_RE = re.compile(
847+
r'\[Mermaid source\]\(([^)]*[/:]mermaid-([0-9a-f]{12})\.txt[^)]*)\)'
848+
)
849+
# Group 1: full PNG image ref. Group 2: 12-char digest.
850+
# The \2 backreference in the optional part ensures the source link digest matches.
851+
# \\? consumes a trailing backslash hard-break that markdownify emits when
852+
# a duplicate source link follows on the next line.
853+
_MERMAID_PAIR_RE = re.compile(
854+
r'(!\[[^\]]*\]\([^)]*[/:]mermaid-([0-9a-f]{12})\.png[^)]*\))'
855+
r'(?:\n+\[Mermaid source\]\([^)]*mermaid-\2\.txt[^)]*\)\\?)?'
856+
)
857+
# Strips any remaining [Mermaid source] links not consumed by _MERMAID_PAIR_RE
858+
# (e.g. duplicate entries from accumulation across multiple push/pull cycles).
859+
_MERMAID_ORPHAN_SOURCE_RE = re.compile(
860+
r'\n*\[Mermaid source\]\([^)]+mermaid-[0-9a-f]{12}\.txt[^)]*\)'
861+
)
862+
863+
864+
def _restore_mermaid_blocks(markdown: str, source_map: dict) -> str:
865+
"""Replace mermaid PNG+source-link pairs with the original mermaid fence.
866+
Strips all remaining [Mermaid source] links so they never appear in the git file."""
867+
def _replace(m):
868+
source = source_map.get(m.group(2))
869+
if source is None:
870+
return m.group(1) # no source available — drop the source link, keep PNG ref
871+
return f"```mermaid\n{source}\n```"
872+
markdown = _MERMAID_PAIR_RE.sub(_replace, markdown)
873+
return _MERMAID_ORPHAN_SOURCE_RE.sub('', markdown)
874+
875+
719876
_LOCAL_IMG_RE = re.compile(r'(!\[[^\]]*\]\()([^\s")]+)((?:\s+"[^"]*")?\))')
877+
_LOCAL_MERMAID_SOURCE_RE = re.compile(r'(\[Mermaid source\]\()([^\s")]+)((?:\s+"[^"]*")?\))')
720878

721879

722880
def _upload_local_images(body: str, client, page_id: str, base_url: str, current_file: Path, dry_run: bool = False) -> str:
@@ -740,7 +898,8 @@ def _replace(m):
740898
except Exception as e:
741899
print(f"FAILED ({e})")
742900
return m.group(0)
743-
return _LOCAL_IMG_RE.sub(_replace, body)
901+
body = _LOCAL_IMG_RE.sub(_replace, body)
902+
return _LOCAL_MERMAID_SOURCE_RE.sub(_replace, body)
744903

745904

746905
def cmd_push(args):
@@ -786,15 +945,26 @@ def cmd_push(args):
786945

787946
body_for_conversion = re.sub(r"^#\s+.+\n", "", body, count=1).strip()
788947
body_for_conversion = _resolve_relative_md_links(body_for_conversion, file_path, config)
789-
body_for_conversion = _upload_local_images(
790-
body_for_conversion, client, page_id,
791-
config.get("confluence_url", ""), file_path, dry_run=args.dry_run,
792-
)
793-
storage_body = markdown_to_storage(
794-
body_for_conversion,
795-
base_url=config.get("confluence_url"),
796-
page_id=page_id,
797-
)
948+
# Strip any orphaned [Mermaid source] links left by a previous incomplete pull
949+
# before rendering, so they don't accumulate across push/pull cycles.
950+
body_for_conversion = _MERMAID_ORPHAN_SOURCE_RE.sub('', body_for_conversion)
951+
with tempfile.TemporaryDirectory() as _mermaid_tmp:
952+
try:
953+
body_for_conversion = _render_mermaid_blocks(
954+
body_for_conversion, Path(_mermaid_tmp), dry_run=args.dry_run,
955+
)
956+
except RuntimeError as e:
957+
print(f"FAILED (mermaid: {e})")
958+
continue
959+
body_for_conversion = _upload_local_images(
960+
body_for_conversion, client, page_id,
961+
config.get("confluence_url", ""), file_path, dry_run=args.dry_run,
962+
)
963+
storage_body = markdown_to_storage(
964+
body_for_conversion,
965+
base_url=config.get("confluence_url"),
966+
page_id=page_id,
967+
)
798968
new_version = remote_version + 1
799969

800970
if args.dry_run:
@@ -808,6 +978,8 @@ def cmd_push(args):
808978
continue
809979

810980
fm["confluence_version"] = new_version
981+
if "mermaid_cache" in fm:
982+
del fm["mermaid_cache"]
811983
updated = _write_front_matter(fm, body)
812984
file_path.write_text(updated, encoding="utf-8")
813985
print(f"ok (now v{new_version})")

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ markdownify>=1.2.2
33
markdown>=3.10.2
44
python-dotenv>=1.2.2
55
PyYAML>=6.0.3
6+
playwright>=1.40.0

0 commit comments

Comments
 (0)