1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 03:57:50 +03:00
Files
android_cast/scripts/generate-docs-toc.py
2026-06-03 06:23:08 +02:00

248 lines
9.2 KiB
Python

#!/usr/bin/env python3
"""Insert/update ## Table of contents in all docs/*.md (GitHub-style anchor links)."""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DOCS = ROOT / "docs"
TOC_START = "<!-- toc -->"
TOC_END = "<!-- /toc -->"
TOC_HEADING = "## Table of contents"
INDEX_NAME = "README.md"
# One-line blurbs for docs/README.md
DOC_BLURBS: dict[str, str] = {
"README.md": "Master index of all documentation (this file).",
"INFRA.md": "Production topology: Gentoo FE → Alpine BE, ports, nginx, sshfs.",
"GIT_FLOW.md": "Branches (`master` / `next`), release, hotfix, dual remote.",
"ROADMAP.md": "Implementation roadmap, alpha definition, deferred work.",
"ALPHA.md": "Alpha feature freeze, QA sign-off, defaults.",
"ALPHA_SOAK.md": "Short LAN soak checklist for demo validation.",
"BUILD_DEPLOY.md": "Docker CI, staging OTA, builder console deploy.",
"OTA.md": "OTA channel layout v0, manifests, MQTT, publish flow.",
"CRASH_REPORTER.md": "Anonymous crash upload (app + backend).",
"OPEN_API.md": "Crash ingest API design, triage tags, heuristics.",
"TICKETS_ROADMAP.md": "Tickets console, Gitea, attachments, agent queue.",
"COMMERCIAL.md": "Developer-gated ads / IAP toggles.",
"PRO_AAR.md": "cast-core / cast-pro AAR split and Play Billing.",
"USB_HDMI_CAST.md": "Roadmap D/E: HDMI awareness, USB-tether transport.",
"GRAFANA_vs_others_graphvis_pivot.md": "Custom graphs vs Grafana decision memo.",
"20260602_REVERSE_SSH_proposals_summary.md": "On-demand remote access (WG/SSH/file API) proposals.",
"AV_QUALITY_QA_SESSION.md": "AV enhancement Q&A: sender vs receiver pipelines.",
}
def github_slug(text: str) -> str:
"""Approximate GitHub/Cursor heading anchor slugs."""
text = text.strip().lower()
text = re.sub(r"`([^`]+)`", r"\1", text)
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
text = re.sub(r"[*_]+", "", text)
text = re.sub(r"[^\w\s-]", "", text)
text = re.sub(r"\s+", "-", text)
text = re.sub(r"-+", "-", text)
return text.strip("-")
def collect_headings(lines: list[str]) -> list[tuple[int, str, str]]:
"""Return (level, title, slug) for ## and ### only."""
out: list[tuple[int, str, str]] = []
used: dict[str, int] = {}
for line in lines:
m = re.match(r"^(#{2,3})\s+(.+?)\s*$", line)
if not m:
continue
level = len(m.group(1))
title = m.group(2).strip()
if title == "Table of contents":
continue
base = github_slug(title)
if not base:
continue
n = used.get(base, 0)
slug = base if n == 0 else f"{base}-{n}"
used[base] = n + 1
out.append((level, title, slug))
return out
def build_toc_block(headings: list[tuple[int, str, str]], *, doc_name: str) -> list[str]:
lines = [TOC_HEADING, "", TOC_START]
for level, title, slug in headings:
indent = " " if level == 3 else ""
# Strip markdown emphasis for link text
link_text = re.sub(r"\*\*([^*]+)\*\*", r"\1", title)
link_text = re.sub(r"`([^`]+)`", r"\1", link_text)
lines.append(f"{indent}- [{link_text}](#{slug})")
lines.append(TOC_END)
if doc_name != INDEX_NAME:
lines.append("")
lines.append(f"**Documentation index:** [{INDEX_NAME}]({INDEX_NAME})")
lines.append("")
return lines
def strip_existing_toc(lines: list[str]) -> list[str]:
out: list[str] = []
i = 0
while i < len(lines):
if lines[i].strip() == TOC_HEADING:
i += 1
while i < len(lines) and TOC_END not in lines[i]:
i += 1
if i < len(lines):
i += 1 # skip <!-- /toc -->
while i < len(lines) and lines[i].strip() == "":
i += 1
if i < len(lines) and lines[i].strip().startswith("**Documentation index:**"):
i += 1
while i < len(lines) and lines[i].strip() == "":
i += 1
continue
out.append(lines[i])
i += 1
return out
def find_insert_index(lines: list[str]) -> int:
"""Index to insert TOC block (before first ## section)."""
for i, line in enumerate(lines):
if re.match(r"^## (?!Table of contents)", line):
return i
return len(lines)
def process_file(path: Path) -> bool:
text = path.read_text(encoding="utf-8")
lines = text.splitlines(keepends=True)
raw = [ln.rstrip("\n") for ln in lines]
raw = strip_existing_toc(raw)
headings = collect_headings(raw)
if not headings and path.name != INDEX_NAME:
return False
toc = build_toc_block(headings, doc_name=path.name)
idx = find_insert_index(raw)
# Avoid duplicate --- before TOC
if idx > 0 and raw[idx - 1].strip() == "---":
new_lines = raw[: idx - 1] + toc + ["---", ""] + raw[idx:]
else:
sep = ["---", ""] if idx < len(raw) and raw[idx].startswith("##") else []
new_lines = raw[:idx] + toc + sep + raw[idx:]
new_text = "\n".join(new_lines) + "\n"
if new_text != text.replace("\r\n", "\n"):
path.write_text(new_text, encoding="utf-8")
return True
return False
def build_index_md() -> str:
md_files = sorted(p for p in DOCS.glob("*.md") if p.name != INDEX_NAME)
lines = [
"# Android Cast — documentation index",
"",
"Click a link to open a guide. Section links jump within the document (GitHub, Cursor, and most Markdown previews).",
"",
TOC_HEADING,
"",
TOC_START,
"- [All documents](#all-documents)",
"- [By topic](#by-topic)",
"- [Agent bootstrap](#agent-bootstrap)",
TOC_END,
"",
"---",
"",
"## All documents",
"",
"| Document | Summary |",
"|----------|---------|",
]
for p in md_files:
name = p.name
blurb = DOC_BLURBS.get(name, "")
lines.append(f"| [{name}]({name}) | {blurb} |")
lines += [
"",
"---",
"",
"## By topic",
"",
"### Infrastructure and deploy",
"",
f"- [INFRA.md](INFRA.md) — FE/BE topology, nginx, ports ([§1 Topology](INFRA.md#1-topology))",
f"- [BUILD_DEPLOY.md](BUILD_DEPLOY.md) — Docker CI, builder ([§Docker CI](BUILD_DEPLOY.md#docker-ci))",
f"- [OTA.md](OTA.md) — OTA v0 layout ([§URL layout](OTA.md#url-layout))",
"",
"### Development process",
"",
f"- [GIT_FLOW.md](GIT_FLOW.md) — `master` / `next` ([§2 Branches](GIT_FLOW.md#2-branches))",
f"- [ROADMAP.md](ROADMAP.md) — milestones ([§Alpha definition](ROADMAP.md#alpha-definition))",
"",
"### Alpha and QA",
"",
f"- [ALPHA.md](ALPHA.md) — freeze and sign-off ([§Alpha scope](ALPHA.md#alpha-scope-what-we-ship))",
f"- [ALPHA_SOAK.md](ALPHA_SOAK.md) — LAN soak ([§Happy path](ALPHA_SOAK.md#happy-path-15-min))",
"",
"### Backend apps",
"",
f"- [CRASH_REPORTER.md](CRASH_REPORTER.md) — crash upload",
f"- [OPEN_API.md](OPEN_API.md) — ingest API ([§Upload API](OPEN_API.md#upload-api-post-apiv1uploadphp))",
f"- [TICKETS_ROADMAP.md](TICKETS_ROADMAP.md) — tickets + Gitea",
f"- [GRAFANA_vs_others_graphvis_pivot.md](GRAFANA_vs_others_graphvis_pivot.md) — graphs pivot",
f"- [20260602_REVERSE_SSH_proposals_summary.md](20260602_REVERSE_SSH_proposals_summary.md) — remote access ([§Rev. 3 Q&A](20260602_REVERSE_SSH_proposals_summary.md#rev-3--wg-vs-ssh-qa--bastion-deep-dive))",
"",
"### Product and codecs",
"",
f"- [COMMERCIAL.md](COMMERCIAL.md) — ads / IAP",
f"- [PRO_AAR.md](PRO_AAR.md) — Pro AAR split",
f"- [USB_HDMI_CAST.md](USB_HDMI_CAST.md) — HDMI / USB-tether",
f"- [AV_QUALITY_QA_SESSION.md](AV_QUALITY_QA_SESSION.md) — AV enhancement Q&A",
"",
"---",
"",
"## Agent bootstrap",
"",
"After a context reset, read in order:",
"",
"1. [INFRA.md](INFRA.md) — topology (no `:8089` for androidcast)",
"2. [GIT_FLOW.md](GIT_FLOW.md) — branch rules",
"3. Task-specific: [BUILD_DEPLOY.md](BUILD_DEPLOY.md), [TICKETS_ROADMAP.md](TICKETS_ROADMAP.md), [20260602_REVERSE_SSH_proposals_summary.md](20260602_REVERSE_SSH_proposals_summary.md), etc.",
"",
"Repo root [AGENTS.md](../AGENTS.md) points here.",
"",
"---",
"",
"## Maintaining TOCs",
"",
"Section links use GitHub/Cursor-style heading anchors. After adding or renaming `##` / `###` headings, run:",
"",
"```bash",
"python3 scripts/generate-docs-toc.py",
"```",
"",
]
return "\n".join(lines) + "\n"
def main() -> None:
index_path = DOCS / INDEX_NAME
index_path.write_text(build_index_md(), encoding="utf-8")
print(f"Wrote {index_path}")
changed = []
for path in sorted(DOCS.glob("*.md")):
if process_file(path):
changed.append(path.name)
print(f"Updated TOC: {path.name}")
print(f"Done. {len(changed)} file(s) updated.")
if __name__ == "__main__":
main()