Last active
October 26, 2025 02:43
-
-
Save RBoelter/3a67c6cf6b693b60d7e02ded32131fe6 to your computer and use it in GitHub Desktop.
Markdown & Mermaid to HTLM & SVG Converter (run: python3 export.py)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | |
| """ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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