mirror of
git://f0xx.org/ac/ac-scripts
synced 2026-07-29 04:18:19 +03:00
308 lines
12 KiB
Python
308 lines
12 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.",
|
||
"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 <!-- /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()
|