- Introduced AuthorityAdvisoryAiOptions and related classes for managing advisory AI configurations, including remote inference options and tenant-specific settings. - Added AuthorityApiLifecycleOptions to control API lifecycle settings, including legacy OAuth endpoint configurations. - Implemented validation and normalization methods for both advisory AI and API lifecycle options to ensure proper configuration. - Created AuthorityNotificationsOptions and its related classes for managing notification settings, including ack tokens, webhooks, and escalation options. - Developed IssuerDirectoryClient and related models for interacting with the issuer directory service, including caching mechanisms and HTTP client configurations. - Added support for dependency injection through ServiceCollectionExtensions for the Issuer Directory Client. - Updated project file to include necessary package references for the new Issuer Directory Client library.
275 lines
8.3 KiB
Python
275 lines
8.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
|
|
import subprocess
|
|
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 = """<!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 run_attestor_validation(repo_root: Path) -> None:
|
|
"""Execute the attestor schema + SDK validation prior to rendering docs."""
|
|
logging.info("Running attestor payload validation (npm run docs:attestor:validate)")
|
|
result = subprocess.run(
|
|
["npm", "run", "docs:attestor:validate"],
|
|
cwd=repo_root,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError("Attestor payload validation failed; aborting docs render.")
|
|
|
|
|
|
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()
|
|
repo_root = Path(__file__).resolve().parents[1]
|
|
|
|
if not source_root.exists():
|
|
logging.error("Source directory %s does not exist", source_root)
|
|
return os.EX_NOINPUT
|
|
|
|
try:
|
|
run_attestor_validation(repo_root)
|
|
except RuntimeError as exc:
|
|
logging.error("%s", exc)
|
|
return os.EX_DATAERR
|
|
|
|
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())
|