#!/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_END = "" 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.", "20260607-2FA-email-mobile-auth-flow.md": "Email forwarding + registration/2FA (alpha must-have).", "20260608_ALPHA_PRIORITIES.md": "**Master priority plan** (0.1–9.x renumber, owners, alpha path).", "20260608_BE_SERVICES_and_infra.md": "BE service map, validation status, SFU preview (gray).", "20260608_STREAM_DUMP_DEBUG.md": "FR 0.1 stream dump spec.", "20260610_SERVICES.md": "Exposed services catalog — web UI, APIs, curl, validation (2026-06-10).", "OPEN_TASKS_GRAPH.md": "Open tasks dependency graph (Mermaid).", "OPUS_SPEEX_VALIDATION.md": "Opus/Speex + UDP stream protection E2E checklist.", "REMOTE_ACCESS_IMPL.md": "WireGuard v1 impl + deploy.", "REMOTE_ACCESS_VALIDATION.md": "Linux CLI suite + mobile parity.", "AV_QUALITY_QA_SESSION.md": "AV enhancement Q&A: sender vs receiver pipelines.", "specs/20100611_3_url_shortener.md": "URL shortener service — SPEC (`s.f0xx.org`, post-alpha).", "DRs/20100611_3_url_shortener.md": "URL shortener — design review (auth, slug algo, task graph).", } SPEC_DIR = DOCS / "specs" DR_DIR = DOCS / "DRs" 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 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)", "- [Specifications](#specifications)", "- [Design reviews](#design-reviews)", "- [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} |") spec_files = sorted(SPEC_DIR.glob("*.md")) if SPEC_DIR.is_dir() else [] dr_files = sorted(DR_DIR.glob("*.md")) if DR_DIR.is_dir() else [] lines += [ "", "---", "", "## Specifications", "", "| Document | Summary |", "|----------|---------|", ] if spec_files: for p in spec_files: rel = f"specs/{p.name}" blurb = DOC_BLURBS.get(rel, "") lines.append(f"| [{p.name}]({rel}) | {blurb} |") else: lines.append("| _(none yet)_ | |") lines += [ "", "---", "", "## Design reviews", "", "| Document | Summary |", "|----------|---------|", ] if dr_files: for p in dr_files: rel = f"DRs/{p.name}" blurb = DOC_BLURBS.get(rel, "") lines.append(f"| [{p.name}]({rel}) | {blurb} |") else: lines.append("| _(none yet)_ | |") 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))", f"- [specs/20100611_3_url_shortener.md](specs/20100611_3_url_shortener.md) — URL shortener SPEC ([§REST API](specs/20100611_3_url_shortener.md#6-rest-api))", f"- [DRs/20100611_3_url_shortener.md](DRs/20100611_3_url_shortener.md) — URL shortener DR ([§Resolved open questions](DRs/20100611_3_url_shortener.md#2-resolved-open-questions))", "", "### 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 = [] toc_paths: list[Path] = sorted(DOCS.glob("*.md")) if SPEC_DIR.is_dir(): toc_paths.extend(sorted(SPEC_DIR.glob("*.md"))) if DR_DIR.is_dir(): toc_paths.extend(sorted(DR_DIR.glob("*.md"))) for path in toc_paths: if process_file(path): changed.append(str(path.relative_to(DOCS))) print(f"Updated TOC: {path.relative_to(DOCS)}") print(f"Done. {len(changed)} file(s) updated.") if __name__ == "__main__": main()