mirror of
git://f0xx.org/android_cast
synced 2026-07-29 05:17:39 +03:00
248 lines
9.2 KiB
Python
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()
|