#!/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 = [
"markdown.extensions.fenced_code",
"markdown.extensions.codehilite",
"markdown.extensions.tables",
"markdown.extensions.toc",
"markdown.extensions.def_list",
"markdown.extensions.admonition",
]
HTML_TEMPLATE = """
{title}
{body}
"""
@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" {entry.title}" f" · {entry.source.as_posix()}"
for entry in sorted(entries, key=lambda e: e.title.lower())
)
html = f"""
Stella Ops Documentation Index
Stella Ops Documentation
Generated on {generated_at} UTC
"""
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())