Some checks failed
Build Test Deploy / authority-container (push) Has been cancelled
Build Test Deploy / docs (push) Has been cancelled
Build Test Deploy / deploy (push) Has been cancelled
Build Test Deploy / build-test (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
255 lines
7.6 KiB
Python
255 lines
7.6 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())
|