Initial commit (history squashed)
This commit is contained in:
254
scripts/render_docs.py
Normal file
254
scripts/render_docs.py
Normal file
@@ -0,0 +1,254 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user