1111"""
1212
1313import argparse
14+ import hashlib
1415import html as html_lib
1516import json
1617import os
1718import re
1819import sys
20+ import tempfile
1921from pathlib import Path
2022from 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
722880def _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
746905def 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 } )" )
0 commit comments