1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 05:17:39 +03:00
Files
android_cast/scripts/generate-docs-toc.py
Anton Afanasyeu 9d9a6d66d9 sync
2026-06-11 18:37:48 +02:00

308 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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.",
"20260607-2FA-email-mobile-auth-flow.md": "Email forwarding + registration/2FA (alpha must-have).",
"20260608_ALPHA_PRIORITIES.md": "**Master priority plan** (0.19.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 <!-- /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)",
"- [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()