255 lines
		
	
	
		
			7.3 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			255 lines
		
	
	
		
			7.3 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| #!/usr/bin/env python3
 | |
| """Render Markdown documentation under docs/ into a static HTML bundle.
 | |
| 
 | |
| The script converts every Markdown file into a standalone HTML document,
 | |
| mirroring the original folder structure under the output directory. A
 | |
| `manifest.json` file is also produced to list the generated documents and
 | |
| surface basic metadata (title, source path, output path).
 | |
| 
 | |
| Usage:
 | |
|     python scripts/render_docs.py --source docs --output build/docs-site
 | |
| 
 | |
| Dependencies:
 | |
|     pip install markdown pygments
 | |
| """
 | |
| 
 | |
| from __future__ import annotations
 | |
| 
 | |
| import argparse
 | |
| import json
 | |
| import logging
 | |
| import os
 | |
| import shutil
 | |
| from dataclasses import dataclass
 | |
| from datetime import datetime, timezone
 | |
| from pathlib import Path
 | |
| from typing import Iterable, List
 | |
| 
 | |
| import markdown
 | |
| 
 | |
| # Enable fenced code blocks, tables, and definition lists. These cover the
 | |
| # Markdown constructs heavily used across the documentation set.
 | |
| MD_EXTENSIONS = [
 | |
|     "fenced_code",
 | |
|     "codehilite",
 | |
|     "tables",
 | |
|     "toc",
 | |
|     "def_list",
 | |
|     "admonition",
 | |
| ]
 | |
| 
 | |
| HTML_TEMPLATE = """<!DOCTYPE html>
 | |
| <html lang=\"en\">
 | |
| <head>
 | |
|   <meta charset=\"utf-8\" />
 | |
|   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
 | |
|   <title>{title}</title>
 | |
|   <style>
 | |
|     :root {{
 | |
|       color-scheme: light dark;
 | |
|       font-family: system-ui, -apple-system, Segoe UI, sans-serif;
 | |
|       line-height: 1.6;
 | |
|     }}
 | |
|     body {{
 | |
|       margin: 2.5rem auto;
 | |
|       padding: 0 1.5rem;
 | |
|       max-width: 70ch;
 | |
|       background: var(--background, #1118270d);
 | |
|     }}
 | |
|     pre {{
 | |
|       overflow: auto;
 | |
|       padding: 1rem;
 | |
|       background: #11182714;
 | |
|       border-radius: 0.5rem;
 | |
|     }}
 | |
|     code {{
 | |
|       font-family: SFMono-Regular, Consolas, 'Liberation Mono', monospace;
 | |
|       font-size: 0.95em;
 | |
|     }}
 | |
|     table {{
 | |
|       width: 100%;
 | |
|       border-collapse: collapse;
 | |
|       margin: 1rem 0;
 | |
|     }}
 | |
|     th, td {{
 | |
|       border: 1px solid #4b5563;
 | |
|       padding: 0.5rem;
 | |
|       text-align: left;
 | |
|     }}
 | |
|     a {{
 | |
|       color: #2563eb;
 | |
|     }}
 | |
|     footer {{
 | |
|       margin-top: 3rem;
 | |
|       font-size: 0.85rem;
 | |
|       color: #6b7280;
 | |
|     }}
 | |
|   </style>
 | |
| </head>
 | |
| <body>
 | |
|   <main>
 | |
| {body}
 | |
|   </main>
 | |
|   <footer>
 | |
|     <p>Generated on {generated_at} UTC · Source: {source}</p>
 | |
|   </footer>
 | |
| </body>
 | |
| </html>
 | |
| """
 | |
| 
 | |
| 
 | |
| @dataclass
 | |
| class DocEntry:
 | |
|     source: Path
 | |
|     output: Path
 | |
|     title: str
 | |
| 
 | |
|     def to_manifest(self) -> dict[str, str]:
 | |
|         return {
 | |
|             "source": self.source.as_posix(),
 | |
|             "output": self.output.as_posix(),
 | |
|             "title": self.title,
 | |
|         }
 | |
| 
 | |
| 
 | |
| def discover_markdown_files(source_root: Path) -> Iterable[Path]:
 | |
|     for path in source_root.rglob("*.md"):
 | |
|         if path.is_file():
 | |
|             yield path
 | |
| 
 | |
| 
 | |
| def read_title(markdown_text: str, fallback: str) -> str:
 | |
|     for raw_line in markdown_text.splitlines():
 | |
|         line = raw_line.strip()
 | |
|         if line.startswith("#"):
 | |
|             return line.lstrip("#").strip() or fallback
 | |
|     return fallback
 | |
| 
 | |
| 
 | |
| def convert_markdown(path: Path, source_root: Path, output_root: Path) -> DocEntry:
 | |
|     relative = path.relative_to(source_root)
 | |
|     output_path = output_root / relative.with_suffix(".html")
 | |
|     output_path.parent.mkdir(parents=True, exist_ok=True)
 | |
| 
 | |
|     text = path.read_text(encoding="utf-8")
 | |
|     html_body = markdown.markdown(text, extensions=MD_EXTENSIONS)
 | |
| 
 | |
|     title = read_title(text, fallback=relative.stem.replace("_", " "))
 | |
|     generated_at = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
 | |
| 
 | |
|     output_path.write_text(
 | |
|         HTML_TEMPLATE.format(
 | |
|             title=title,
 | |
|             body=html_body,
 | |
|             generated_at=generated_at,
 | |
|             source=relative.as_posix(),
 | |
|         ),
 | |
|         encoding="utf-8",
 | |
|     )
 | |
| 
 | |
|     return DocEntry(source=relative, output=output_path.relative_to(output_root), title=title)
 | |
| 
 | |
| 
 | |
| def copy_static_assets(source_root: Path, output_root: Path) -> None:
 | |
|     for path in source_root.rglob("*"):
 | |
|         if path.is_dir() or path.suffix.lower() == ".md":
 | |
|             # Skip Markdown (already rendered separately).
 | |
|             continue
 | |
|         relative = path.relative_to(source_root)
 | |
|         destination = output_root / relative
 | |
|         destination.parent.mkdir(parents=True, exist_ok=True)
 | |
|         destination.write_bytes(path.read_bytes())
 | |
|         logging.info("Copied asset %s", relative)
 | |
| 
 | |
| 
 | |
| def write_manifest(entries: Iterable[DocEntry], output_root: Path) -> None:
 | |
|     manifest_path = output_root / "manifest.json"
 | |
|     manifest = [entry.to_manifest() for entry in entries]
 | |
|     manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
 | |
|     logging.info("Wrote manifest with %d entries", len(manifest))
 | |
| 
 | |
| 
 | |
| def write_index(entries: List[DocEntry], output_root: Path) -> None:
 | |
|     index_path = output_root / "index.html"
 | |
|     generated_at = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
 | |
| 
 | |
|     items = "\n".join(
 | |
|         f"      <li><a href='{entry.output.as_posix()}'>{entry.title}</a>" f" · <code>{entry.source.as_posix()}</code></li>"
 | |
|         for entry in sorted(entries, key=lambda e: e.title.lower())
 | |
|     )
 | |
| 
 | |
|     html = f"""<!DOCTYPE html>
 | |
| <html lang=\"en\">
 | |
| <head>
 | |
|   <meta charset=\"utf-8\" />
 | |
|   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
 | |
|   <title>Stella Ops Documentation Index</title>
 | |
|   <style>
 | |
|     body {{
 | |
|       margin: 2.5rem auto;
 | |
|       padding: 0 1.5rem;
 | |
|       max-width: 70ch;
 | |
|       font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
 | |
|       line-height: 1.6;
 | |
|     }}
 | |
|     h1 {{ font-size: 2.25rem; margin-bottom: 1rem; }}
 | |
|     ul {{ list-style: none; padding: 0; }}
 | |
|     li {{ margin-bottom: 0.75rem; }}
 | |
|     code {{ background: #11182714; padding: 0.2rem 0.35rem; border-radius: 0.35rem; }}
 | |
|   </style>
 | |
| </head>
 | |
| <body>
 | |
|   <h1>Stella Ops Documentation</h1>
 | |
|   <p>Generated on {generated_at} UTC</p>
 | |
|   <ul>
 | |
| {items}
 | |
|   </ul>
 | |
| </body>
 | |
| </html>
 | |
| """
 | |
|     index_path.write_text(html, encoding="utf-8")
 | |
|     logging.info("Wrote HTML index with %d entries", len(entries))
 | |
| 
 | |
| 
 | |
| def parse_args() -> argparse.Namespace:
 | |
|     parser = argparse.ArgumentParser(description="Render documentation bundle")
 | |
|     parser.add_argument("--source", default="docs", type=Path, help="Directory containing Markdown sources")
 | |
|     parser.add_argument("--output", default=Path("build/docs-site"), type=Path, help="Directory for rendered output")
 | |
|     parser.add_argument("--clean", action="store_true", help="Remove the output directory before rendering")
 | |
|     return parser.parse_args()
 | |
| 
 | |
| 
 | |
| def main() -> int:
 | |
|     logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
 | |
|     args = parse_args()
 | |
| 
 | |
|     source_root: Path = args.source.resolve()
 | |
|     output_root: Path = args.output.resolve()
 | |
| 
 | |
|     if not source_root.exists():
 | |
|         logging.error("Source directory %s does not exist", source_root)
 | |
|         return os.EX_NOINPUT
 | |
| 
 | |
|     if args.clean and output_root.exists():
 | |
|         logging.info("Cleaning existing output directory %s", output_root)
 | |
|         shutil.rmtree(output_root)
 | |
| 
 | |
|     output_root.mkdir(parents=True, exist_ok=True)
 | |
| 
 | |
|     entries: List[DocEntry] = []
 | |
|     for md_file in discover_markdown_files(source_root):
 | |
|         entry = convert_markdown(md_file, source_root, output_root)
 | |
|         entries.append(entry)
 | |
|         logging.info("Rendered %s -> %s", entry.source, entry.output)
 | |
| 
 | |
|     write_manifest(entries, output_root)
 | |
|     write_index(entries, output_root)
 | |
|     copy_static_assets(source_root, output_root)
 | |
| 
 | |
|     logging.info("Documentation bundle available at %s", output_root)
 | |
|     return os.EX_OK
 | |
| 
 | |
| 
 | |
| if __name__ == "__main__":
 | |
|     raise SystemExit(main())
 |