import re
import sys
import argparse
from pathlib import Path
from typing import List, Dict, Any, Optional, Tuple, Set
try:
import yaml
except ImportError:
print("Error: PyYAML is required. Install with: pip install pyyaml")
sys.exit(1)
class MdBookMkDocsSync:
def __init__(
self,
summary_path: Path,
mkdocs_path: Path,
rename_files: bool = False,
dry_run: bool = False
):
self.summary_path = summary_path
self.mkdocs_path = mkdocs_path
self.rename_files = rename_files
self.dry_run = dry_run
self.docs_dir = self._detect_docs_dir()
self.changes = []
def _detect_docs_dir(self) -> Path:
if not self.mkdocs_path.exists():
return self.summary_path.parent
with open(self.mkdocs_path, 'r') as f:
for line in f:
if line.startswith('docs_dir:'):
docs_dir = line.split(':', 1)[1].strip()
docs_dir = docs_dir.strip('"').strip("'")
return Path(docs_dir)
return Path('docs')
def parse_summary(self) -> List[Dict[str, Any]]:
nav = []
current_section = None
current_section_name = None
section_items = []
home_page_found = False
processed_lines = set()
with open(self.summary_path, 'r') as f:
lines = f.readlines()
line_num = 0
while line_num < len(lines):
line = lines[line_num].rstrip()
line_num += 1
if not line:
continue
if line_num in processed_lines:
continue
if line.startswith('#'):
if current_section_name and section_items:
nav.append({current_section_name: section_items})
section_items = []
section_header = line.lstrip('#').strip()
if section_header.lower() == 'summary':
continue
current_section_name = self._normalize_section_name(section_header)
current_section = section_header
self.changes.append(f"Found section: {section_header} → {current_section_name}")
continue
match = re.match(r'^(\s*)(?:- )?\[(.+?)\]\((.+?)\)', line)
if not match:
continue
indent_str, title, path = match.groups()
indent_level = len(indent_str) // 2
if indent_level > 0:
continue
original_path = path
if self._is_home_page(path):
path = 'index.md'
home_page_found = True
if original_path != 'index.md':
self.changes.append(f"Map home page: {original_path} → {path}")
nav.insert(0, {'Home': path})
continue
nav_item, consumed_lines = self._parse_nav_item_with_tracking(
lines, line_num - 1, title, path, indent_level
)
processed_lines.update(consumed_lines)
if current_section_name:
section_items.append(nav_item)
else:
nav.append(nav_item)
if current_section_name and section_items:
nav.append({current_section_name: section_items})
if not home_page_found:
self.changes.append("Warning: No home page found (intro.md or index.md)")
return nav
def _normalize_section_name(self, section: str) -> str:
mapping = {
'User Guide': 'User Guide',
'Advanced Topics': 'Advanced',
'Reference': 'Reference',
'API Reference': 'API',
'Developer Guide': 'Development',
}
return mapping.get(section, section)
def _parse_nav_item_with_tracking(
self,
lines: List[str],
current_line: int,
title: str,
path: str,
indent_level: int
) -> Tuple[Dict[str, Any], Set[int]]:
consumed_lines = set()
has_children = False
if current_line + 1 < len(lines):
next_line = lines[current_line + 1]
next_match = re.match(r'^(\s*)- \[', next_line)
if next_match:
next_indent = len(next_match.group(1)) // 2
has_children = next_indent > indent_level
if has_children or path.endswith('/index.md'):
children = []
i = current_line + 1
while i < len(lines):
child_line = lines[i].rstrip()
if child_line.startswith('#'):
break
if not child_line:
i += 1
continue
child_match = re.match(r'^(\s*)- \[(.+?)\]\((.+?)\)', child_line)
if child_match:
child_indent_str, child_title, child_path = child_match.groups()
child_indent = len(child_indent_str) // 2
if child_indent <= indent_level:
break
if child_indent == indent_level + 1:
children.append({child_title: child_path})
consumed_lines.add(i + 1)
i += 1
if path and not path.endswith('/'):
return {title: [path] + children}, consumed_lines
else:
return {title: children}, consumed_lines
else:
return {title: path}, consumed_lines
def _is_home_page(self, path: str) -> bool:
home_pages = ['intro.md', 'readme.md', 'introduction.md']
return path.lower() in home_pages or path == 'index.md'
def update_mkdocs_nav(self, nav: List[Dict[str, Any]]):
if not self.mkdocs_path.exists():
print(f"✗ Error: {self.mkdocs_path} not found")
return False
with open(self.mkdocs_path, 'r') as f:
content = f.read()
old_nav_count = content.count('\n - ') + content.count('\n - ')
if self.dry_run:
print(f"\n[DRY RUN] Would update {self.mkdocs_path}")
print(f" Old nav items: ~{old_nav_count}")
print(f" New nav items: {len(nav)}")
return True
return self._update_nav_in_place(content, nav)
def _update_nav_in_place(self, content: str, nav: List[Dict[str, Any]]) -> bool:
nav_yaml = yaml.dump(nav, default_flow_style=False, sort_keys=False, allow_unicode=True, width=1000)
nav_lines = nav_yaml.rstrip().split('\n')
indented_nav = '\n'.join(' ' + line if line else '' for line in nav_lines)
pattern = r'^nav:.*?\n((?:[ \t]+.*\n|\s*\n)*?)(?=^[a-zA-Z]|\Z)'
def replace_nav(match):
return f'nav:\n{indented_nav}\n\n'
new_content, count = re.subn(pattern, replace_nav, content, flags=re.MULTILINE | re.DOTALL)
if count == 0:
new_content = content.rstrip() + '\n\n# Navigation\nnav:\n' + indented_nav + '\n'
with open(self.mkdocs_path, 'w') as f:
f.write(new_content)
print(f"✓ Updated {self.mkdocs_path}")
print(f" Navigation items: {len(nav)}")
return True
def rename_home_page(self):
intro_path = self.docs_dir / 'intro.md'
index_path = self.docs_dir / 'index.md'
if not intro_path.exists():
return
if index_path.exists():
print(f"ℹ️ {index_path} already exists, skipping rename")
return
if self.dry_run:
print(f"\n[DRY RUN] Would rename {intro_path} → {index_path}")
self._show_summary_updates('intro.md', 'index.md')
return
intro_path.rename(index_path)
print(f"✓ Renamed {intro_path} → {index_path}")
self.changes.append(f"Renamed: {intro_path.name} → {index_path.name}")
self._update_summary_references('intro.md', 'index.md')
def _update_summary_references(self, old_name: str, new_name: str):
with open(self.summary_path, 'r') as f:
content = f.read()
pattern = r'\((' + re.escape(old_name) + r')\)'
updated_content = re.sub(pattern, f'({new_name})', content)
if content == updated_content:
return
with open(self.summary_path, 'w') as f:
f.write(updated_content)
print(f"✓ Updated {self.summary_path} references")
def _show_summary_updates(self, old_name: str, new_name: str):
with open(self.summary_path, 'r') as f:
content = f.read()
pattern = r'\((' + re.escape(old_name) + r')\)'
matches = re.findall(pattern, content)
if matches:
print(f" Would update {len(matches)} reference(s) in {self.summary_path}")
def run(self) -> int:
print("🔄 mdbook → mkdocs Navigation Sync")
print("=" * 50)
if not self.summary_path.exists():
print(f"✗ Error: {self.summary_path} not found")
return 1
if not self.mkdocs_path.exists():
print(f"✗ Error: {self.mkdocs_path} not found")
print(f" Create a basic mkdocs.yml first with:")
print(f" docs_dir: {self.docs_dir}")
return 1
print(f"\n📖 Parsing {self.summary_path}...")
nav = self.parse_summary()
if self.changes:
print(f"\n📝 Detected changes:")
for change in self.changes:
print(f" • {change}")
if self.rename_files:
print(f"\n📁 Renaming files...")
self.rename_home_page()
print(f"\n📝 Updating {self.mkdocs_path}...")
if not self.update_mkdocs_nav(nav):
return 1
print(f"\n✅ Sync complete!")
print(f" mdbook uses: {self.summary_path}")
print(f" mkdocs uses: {self.mkdocs_path}")
print(f"\nBoth navigation structures now reference the same source files in {self.docs_dir}/")
if self.dry_run:
print("\n💡 This was a dry run. Use without --dry-run to apply changes.")
return 0
def main():
parser = argparse.ArgumentParser(
description='Sync mdbook SUMMARY.md to mkdocs.yml navigation',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s # Basic usage
%(prog)s --rename-files # Rename intro.md to index.md
%(prog)s --dry-run # Preview changes
%(prog)s --summary docs/SUMMARY.md # Custom paths
For more info: https://github.com/iepathos/mdbook-mkdocs-sync
"""
)
parser.add_argument(
'--summary',
type=Path,
default=Path('book/src/SUMMARY.md'),
help='Path to SUMMARY.md (default: book/src/SUMMARY.md)'
)
parser.add_argument(
'--mkdocs',
type=Path,
default=Path('mkdocs.yml'),
help='Path to mkdocs.yml (default: mkdocs.yml)'
)
parser.add_argument(
'--rename-files',
action='store_true',
help='Rename intro.md to index.md and update SUMMARY.md'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be done without making changes'
)
parser.add_argument(
'--version',
action='version',
version='%(prog)s 0.1.0'
)
args = parser.parse_args()
syncer = MdBookMkDocsSync(
summary_path=args.summary,
mkdocs_path=args.mkdocs,
rename_files=args.rename_files,
dry_run=args.dry_run
)
return syncer.run()
if __name__ == '__main__':
sys.exit(main())