Skip to content

Instantly share code, notes, and snippets.

@RBoelter
Last active October 26, 2025 02:43
Show Gist options
  • Select an option

  • Save RBoelter/3a67c6cf6b693b60d7e02ded32131fe6 to your computer and use it in GitHub Desktop.

Select an option

Save RBoelter/3a67c6cf6b693b60d7e02ded32131fe6 to your computer and use it in GitHub Desktop.
Markdown & Mermaid to HTLM & SVG Converter (run: python3 export.py)
from pathlib import Path
from MarkdownConverter import MarkdownConverter
from HtmlCombiner import HtmlCombiner
def main():
SRC_DIR = Path("../")
EXPORT_DIR = Path("./export")
converter = MarkdownConverter(src_dir=SRC_DIR, export_dir=EXPORT_DIR)
converter.convert_all()
combiner = HtmlCombiner(export_dir=EXPORT_DIR)
combiner.create_index()
print(f"\nProcess finished. Files are in {EXPORT_DIR}")
if __name__ == "__main__":
main()
import re
from pathlib import Path
class HtmlCombiner:
def __init__(self, export_dir: Path):
self.export_dir = export_dir
self.template = self._get_html_template()
def create_index(self):
sidebar_content = self._read_file_content("_sidebar.html")
toc_match = re.search(r'.*</ul>', sidebar_content, re.DOTALL)
if toc_match:
sidebar_content = toc_match.group(0)
sidebar_content = self._fix_sidebar_links(sidebar_content)
footer_content = self._read_file_content("_footer.html")
page_files_to_embed = self._get_page_files()
all_pages_content, initial_page_id = self._embed_all_pages(page_files_to_embed)
if not all_pages_content:
all_pages_content = "<h1>Welcome</h1><p>No content pages found.</p>"
initial_page_id = ""
final_html = self.template.replace("{{sidebar_content}}", sidebar_content)
final_html = final_html.replace("{{footer_content}}", footer_content)
final_html = final_html.replace("{{all_pages_content}}", all_pages_content)
final_html = final_html.replace("{{initial_page_id}}", initial_page_id)
index_path = self.export_dir / "index.html"
index_path.write_text(final_html, encoding="utf-8")
print(f"index.html created successfully.")
self._cleanup_temporary_files(page_files_to_embed)
def _get_page_files(self) -> list[Path]:
return [
p for p in self.export_dir.glob("*.html")
if not p.name.startswith('_') and p.name != 'index.html'
]
def _embed_all_pages(self, page_files: list[Path]) -> tuple[str, str]:
html_contents = []
initial_page_id = ""
home_file = self.export_dir / "Home.html"
if home_file in page_files:
page_files.remove(home_file)
page_files.insert(0, home_file)
for i, file_path in enumerate(page_files):
page_id = file_path.stem
content = self._read_file_content(file_path.name, is_page_content=True)
display_style = "block" if i == 0 else "none"
html_contents.append(f'<div class="page-content" id="page-{page_id}" style="display: {display_style};">{content}</div>')
if i == 0:
initial_page_id = f"page-{page_id}"
return "\n".join(html_contents), initial_page_id
def _cleanup_temporary_files(self, page_files: list[Path]):
print("Cleaning up temporary HTML files...")
files_to_delete = page_files + list(self.export_dir.glob("_*.html"))
for file_path in files_to_delete:
if file_path.exists():
try:
file_path.unlink()
print(f" - Deleted '{file_path.name}'")
except OSError as e:
print(f"Error deleting {file_path.name}: {e}")
def _fix_sidebar_links(self, html_content: str) -> str:
def process_link(match):
tag_start = match.group(1)
href_value = match.group(2)
tag_end = match.group(3)
is_external = href_value.startswith(('http:', 'https:'))
is_anchor = href_value.startswith('#')
if is_external:
return f'{tag_start}{href_value}" target="_blank" rel="noopener noreferrer"{tag_end}'
elif not is_anchor:
page_name = Path(href_value).stem
return f'{tag_start}#page-{page_name}{tag_end}'
else:
return match.group(0)
return re.sub(r'(<a\s+[^>]*?href=")([^"]*)(".*?>)', process_link, html_content, flags=re.IGNORECASE)
def _read_file_content(self, filename: str, is_page_content: bool = False) -> str:
try:
path = self.export_dir / filename
content = path.read_text(encoding="utf-8")
if is_page_content:
body_match = re.search(r"<body.*?>(.*)</body>", content, re.DOTALL)
return body_match.group(1).strip() if body_match else content
return content
except FileNotFoundError:
if filename.startswith('_'):
print(f"Warning: File '{filename}' not found in export directory.")
return ""
@staticmethod
def _get_html_template() -> str:
return """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Documentation</title>
<style>
:root {
--bg-color: #1a1a1a; --sidebar-bg: #222; --primary-text: #e0e0e0;
--secondary-text: #b0b0b0; --border-color: #333; --accent-color: #61afef;
--hover-bg: #333; --code-bg: #2c2c2c; --diagram-bg: #f9f9f9;
}
body {
background-color: var(--bg-color); color: var(--primary-text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 17px; line-height: 1.7; margin: 0; display: flex; height: 100vh; overflow: hidden;
}
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: var(--sidebar-bg); }
::-webkit-scrollbar-thumb { background-color: #555; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background-color: #777; }
#sidebar {
min-width: 280px; flex-shrink: 0; background-color: var(--sidebar-bg);
padding: 20px; border-right: 1px solid var(--border-color);
overflow-y: auto; white-space: nowrap;
}
#sidebar a {
display: block; color: var(--secondary-text); text-decoration: none;
padding: 8px 12px; border-radius: 5px;
transition: background-color 0.2s ease, color 0.2s ease;
}
#sidebar a:hover, #sidebar a.active {
background-color: var(--hover-bg); color: var(--primary-text);
}
#main-wrapper {
flex-grow: 1; display: flex; flex-direction: column;
max-height: 100vh; min-width: 0;
}
#content-container {
padding: 30px 50px; overflow-y: auto; flex-grow: 1; white-space: normal;
}
.page-content h1, .page-content h2, .page-content h3, .page-content h4 {
color: #ffffff; border-bottom: 1px solid var(--border-color);
padding-bottom: 0.3em; margin-top: 1.5em; margin-bottom: 1em;
}
.page-content a { color: var(--accent-color); text-decoration: none; }
.page-content a:hover { text-decoration: underline; }
.page-content code {
background-color: var(--code-bg); padding: 0.2em 0.4em;
margin: 0; font-size: 85%; border-radius: 3px;
}
.page-content pre {
background-color: var(--code-bg); padding: 1em;
border-radius: 5px; overflow-x: auto;
}
.page-content pre code { padding: 0; background: none; }
.page-content img { max-width: 100%; height: auto; border-radius: 8px; }
.page-content img[src$=".svg"] {
background-color: var(--diagram-bg); padding: 20px;
box-shadow: 0 4px 8px rgba(0,0,0,0.3); display: block; margin: 1.5em 0;
}
#footer {
padding: 20px 50px; border-top: 1px solid var(--border-color);
background-color: var(--sidebar-bg); text-align: center;
font-size: 0.9em; color: #888; flex-shrink: 0;
}
</style>
</head>
<body>
<aside id="sidebar">{{sidebar_content}}</aside>
<div id="main-wrapper">
<main id="content-container">{{all_pages_content}}</main>
<footer id="footer">{{footer_content}}</footer>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const sidebar = document.getElementById('sidebar');
const contentContainer = document.getElementById('content-container');
let activePageId = '{{initial_page_id}}';
let activeLink = sidebar.querySelector(`a[href="#${activePageId}"]`);
const adjustSidebarWidth = () => {
const scrollWidth = sidebar.scrollWidth;
const currentWidth = sidebar.clientWidth;
if (scrollWidth > currentWidth) {
sidebar.style.width = `${scrollWidth + 20}px`;
}
};
const showPage = (pageId) => {
if (!pageId || pageId === activePageId) return;
const currentPage = document.getElementById(activePageId);
if (currentPage) currentPage.style.display = 'none';
const newPage = document.getElementById(pageId);
if (newPage) {
newPage.style.display = 'block';
activePageId = pageId;
contentContainer.scrollTop = 0;
}
if(activeLink) activeLink.classList.remove('active');
activeLink = sidebar.querySelector(`a[href="#${pageId}"]`);
if(activeLink) activeLink.classList.add('active');
};
sidebar.addEventListener('click', (event) => {
const target = event.target.closest('a');
if (target && target.hash) {
const pageId = target.hash.substring(1);
if (pageId && !target.hasAttribute('target')) {
event.preventDefault();
showPage(pageId);
history.pushState(null, null, target.hash.replace('page-','#'));
}
}
});
if(activeLink) activeLink.classList.add('active');
adjustSidebarWidth();
});
</script>
</body>
</html>
"""
import shutil
import subprocess
import re
from pathlib import Path
class MarkdownConverter:
def __init__(self, src_dir: Path, export_dir: Path, tmp_dir_name: str = "tmp_mmd"):
self.src_dir = src_dir
self.export_dir = export_dir
self.svg_dir = self.export_dir / "svg"
self.tmp_dir = self.export_dir / tmp_dir_name
self.mermaid_pattern = re.compile(r"```mermaid(.*?)```", re.DOTALL)
def convert_all(self):
self._setup_directories()
try:
for file in self.src_dir.glob("*.md"):
self._process_file(file)
finally:
self._cleanup()
print(f"\nMarkdown conversion completed.")
def _process_file(self, file_path: Path):
filename_stem = file_path.stem
workfile = self.tmp_dir / f"{filename_stem}.md"
shutil.copy(file_path, workfile)
content = workfile.read_text(encoding="utf-8")
modified_content = self._replace_mermaid_blocks(content, filename_stem)
workfile.write_text(modified_content, encoding="utf-8")
self._convert_to_html(workfile, filename_stem)
def _replace_mermaid_blocks(self, content: str, filename_stem: str) -> str:
block_num = 0
while (match := self.mermaid_pattern.search(content)):
block_num += 1
mmd_content = match.group(1).strip()
svg_file_name = f"{filename_stem}_{block_num}.svg"
self._create_svg_from_mermaid(mmd_content, svg_file_name, filename_stem, block_num)
img_tag = f'\n<img src="svg/{svg_file_name}" alt="Mermaid diagram">\n'
content = self.mermaid_pattern.sub(img_tag, content, 1)
return content
def _create_svg_from_mermaid(self, mmd_content: str, svg_file_name: str, filename_stem: str, block_num: int):
mmd_file = self.tmp_dir / f"{filename_stem}_{block_num}.mmd"
mmd_file.write_text(mmd_content, encoding="utf-8")
svg_path = self.svg_dir / svg_file_name
print(f"Generating diagram: {svg_path}")
self._run_command(["mmdc", "-i", str(mmd_file), "-o", str(svg_path)])
def _convert_to_html(self, workfile: Path, filename_stem: str):
html_file_path = self.export_dir / f"{filename_stem}.html"
print(f"Converting {workfile.name} to HTML...")
self._run_command(["pandoc", str(workfile), "-f", "gfm", "-t", "html", "-o", str(html_file_path)])
def _setup_directories(self):
self.export_dir.mkdir(exist_ok=True)
self.svg_dir.mkdir(exist_ok=True)
self.tmp_dir.mkdir(exist_ok=True)
def _cleanup(self):
if self.tmp_dir.exists():
shutil.rmtree(self.tmp_dir)
@staticmethod
def _run_command(command):
try:
subprocess.run(command, check=True, capture_output=True, text=True)
except subprocess.CalledProcessError as e:
print(f"Error executing: {' '.join(command)}")
print(f"Stderr: {e.stderr}")
raise
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment