samayly.com is live. Hosted plans open soon. Join the waitlist →
python201 linescopy and edit

scripts/ts03-docs-index

Copy this file into your setup and edit the marked values. Do not use it as-is.

scripts/ts03-docs-index
#!/usr/bin/env python3
"""ts03-docs-index — write docs/INDEX.md: every guide and every diagram, by name.

Each Mermaid diagram is named by the bold caption line just above it (for example **System architecture**).
A diagram without a caption takes the name of the section heading it sits under. When that heading only repeats
the guide's title, or several diagrams share it, the name is built from the diagram itself: the heading plus its
first and last node, for example "Setup: Create project to Download JSON". Diagrams are numbered
per guide: 14.1, 14.2 ... (README diagrams are R.1, R.2 ...). The file is generated: edit the docs, then
re-run this script and commit docs/INDEX.md.

Usage
    ./scripts/ts03-docs-index            write docs/INDEX.md
    ./scripts/ts03-docs-index --check    exit 1 if docs/INDEX.md is out of date (writes nothing)
    ./scripts/ts03-docs-index --root DIR use DIR as the repo root (default: the current folder)

Standard library only.
"""
import argparse
import os
import re
import sys

TYPES = {"sequencediagram": "sequence", "statediagram": "state", "statediagram-v2": "state", "classdiagram": "class",
         "erdiagram": "er", "journey": "journey", "gantt": "gantt", "pie": "pie", "mindmap": "mindmap"}

WHERE_TO_LOOK = [
    ("every command, with paste-ready group messages", "Guide 13", "13-commands-cheatsheet.md", ""),
    ("every way to run it (iPhone SSH, Mac, GUI, CLI) and the prompt behind each command", "Guide 15, sections 6 to 8", "15-run-it-everywhere/README.md", "#6-cli-reference"),
    ("class schedule commands, config, areas and instructors", "Guide 16", "16-class-schedules/README.md", "#4-commands-and-prompts"),
    ("how a message is routed, and what was fixed", "Guide 14", "14-architecture-routing-and-fixes/README.md", "#2-how-every-message-is-routed"),
    ("`/prep` and the Drive study material", "Guide 12", "12-study-material-prep.md", ""),
]


def slug(text, seen):
    """GitHub-style heading anchor."""
    s = re.sub(r"[^\w\- ]", "", text.strip().lower()).replace(" ", "-")
    n = seen.get(s, 0)
    seen[s] = n + 1
    return s if n == 0 else f"{s}-{n}"


def diagram_type(block):
    first = (block.strip().split("\n", 1)[0].split() or ["diagram"])[0].lower()
    return TYPES.get(first, first)


EMOJI = re.compile("[\U0001F000-\U0001FAFF\u2600-\u27BF\uFE0F\u200d]")


def node_labels(block):
    """Visible labels of a diagram's nodes (or participants), in order, tidied for a name."""
    if block.strip().startswith("sequenceDiagram"):
        raw = re.findall(r"participant\s+\w+\s+as\s+(.+)", block)
    else:
        raw = [m.group(1) for m in re.finditer(
            r'(?<![\w"])[A-Za-z][A-Za-z0-9_]*\s*(?:\[\(|\(\(|\[|\{|\()\s*"?(.*?)"?\s*(?:\)\]|\)\)|\]|\}|\))(?=\s|$|-|:|;)', block)]
    out = []
    for label in raw:
        lines = [EMOJI.sub("", x).strip() for x in re.split(r"<br\s*/?>", label)]
        label = lines[0]                                                # a node's first line is its title...
        if len(label) < 12 and len(lines) > 1:                          # ...unless it is only "Step 1" or "You"
            label = f"{label} {lines[1]}"
        label = re.sub(r"\s+", " ", re.sub(r"[`*\"]", "", label)).strip()
        label = re.sub(r"\s*\(.*$", "", label).strip() or label        # drop trailing parentheticals
        if len(label) > 36:
            cut = label[:35].rsplit(" ", 1)[0] if " " in label[:35] else label[:35]
            label = cut.rstrip(" ,;:+-") + "…"
        if label and label not in out:
            out.append(label)
    return out


def norm_title(text):
    return re.sub(r"^(?:Guide\s+)?\d+\s*[—:.\-]\s*", "", text or "").strip().lower()


def clean_caption(line):
    m = re.fullmatch(r"\*\*(.+?)\*\*:?", line.strip())
    return re.sub(r"[`*]", "", m.group(1)).strip() if m else None


def scan(path):
    """-> (title, [diagram dicts]) for one markdown file."""
    lines = open(path, encoding="utf-8").read().split("\n")
    title, heading, anchor, seen = "", "", "", {}
    out, in_fence, fence_kind, block, prev = [], False, "", [], ""
    under_heading = {}
    for line in lines:
        if line.startswith("```"):
            if not in_fence:
                in_fence, fence_kind, block = True, line[3:].strip(), []
                if fence_kind == "mermaid":
                    cap = clean_caption(prev)
                    out.append({"caption": cap, "heading": heading, "anchor": anchor, "type": "", "block": ""})
            else:
                if fence_kind == "mermaid" and out:
                    out[-1]["type"] = diagram_type("\n".join(block))
                    out[-1]["labels"] = node_labels("\n".join(block))
                in_fence = False
            prev = line
            continue
        if in_fence:
            block.append(line)
            continue
        m = re.match(r"(#{1,6})\s+(.*)", line)
        if m:
            heading = re.sub(r"[`*]", "", m.group(2)).strip()
            anchor = slug(m.group(2), seen)
            if len(m.group(1)) == 1 and not title:
                title = heading
        if line.strip():
            prev = line
    plain = {}
    for d in out:
        if not d["caption"]:
            plain[d["heading"]] = plain.get(d["heading"], 0) + 1
    for d in out:
        labels = d.get("labels") or []
        if d["caption"]:
            d["name"] = d["caption"]
        elif len(labels) >= 2 and (norm_title(d["heading"]) == norm_title(title) or plain[d["heading"]] > 1):
            head = re.sub(r"^\d+\s*[.:\-]\s*", "", d["heading"]).strip()
            d["name"] = f"{head}: {labels[0]} to {labels[-1]}"
        else:
            d["name"] = d["heading"]
    return title, out


def guide_number(rel):
    m = re.match(r"docs/(\d+)-[^/]+(?:\.md|/README\.md)$", rel)
    return int(m.group(1)) if m else None


def build(root):
    files = []
    if os.path.exists(os.path.join(root, "README.md")):
        files.append(("README.md", "R"))
    docs = os.path.join(root, "docs")
    for name in sorted(os.listdir(docs)):
        full = os.path.join(docs, name)
        rel = f"docs/{name}"
        if os.path.isdir(full) and os.path.exists(os.path.join(full, "README.md")):
            rel += "/README.md"
        elif not name.endswith(".md"):
            continue
        n = guide_number(rel)
        if n is not None:
            files.append((rel, n))
    guides, diagrams = [], []
    for rel, key in files:
        title, ds = scan(os.path.join(root, rel))
        link = ("../" + rel) if key == "R" else rel[len("docs/"):]
        if key != "R":
            guides.append((key, re.sub(r"^(?:Guide\s+)?\d+\s*[—:.\-]\s*", "", title) or rel, link, len(ds)))
        for i, d in enumerate(ds, 1):
            diagrams.append((f"{key}.{i}", d["name"], d["type"], f"{link}#{d['anchor']}" if d["anchor"] else link, key))
    diagrams.sort(key=lambda x: (0, 0, int(x[0].split(".")[1])) if x[4] == "R" else (1, x[4], int(x[0].split(".")[1])))

    esc = lambda s: s.replace("|", "\\|")
    out = ["# Docs index", "",
           "Every guide and every diagram, by name. Generated by `scripts/ts03-docs-index`: after you edit a doc, run "
           "`./scripts/ts03-docs-index` and commit this file. A diagram is named by the bold caption above it, "
           "or by the heading it sits under.", "",
           "## Guides", "", "| # | Guide | Diagrams |", "|---|---|---|"]
    for n, title, link, count in guides:
        out.append(f"| {n} | [{esc(title)}]({link}) | {count} |")
    out += ["", "## Diagrams", "", "| ID | Name | Type | Where |", "|---|---|---|---|"]
    for did, name, typ, link, key in diagrams:
        where = "README" if key == "R" else f"Guide {key}"
        out.append(f"| {did} | {esc(name)} | {typ} | [{where}]({link}) |")
    out += ["", "## Commands and prompts: where to look", "", "| I want | Read |", "|---|---|"]
    for want, label, path, frag in WHERE_TO_LOOK:
        if os.path.exists(os.path.join(root, "docs", path)):
            out.append(f"| {want} | [{label}]({path}{frag}) |")
    return "\n".join(out) + "\n", len(guides), len(diagrams)


def main():
    ap = argparse.ArgumentParser(description="Write docs/INDEX.md: every guide and diagram, by name.")
    ap.add_argument("--root", default=".", help="repo root (default: current folder)")
    ap.add_argument("--check", action="store_true", help="exit 1 if docs/INDEX.md is out of date")
    args = ap.parse_args()
    if not os.path.isdir(os.path.join(args.root, "docs")):
        sys.exit("Run this from the repo root (the folder that contains docs/), or pass --root.")
    text, n_guides, n_diagrams = build(args.root)
    target = os.path.join(args.root, "docs", "INDEX.md")
    current = open(target, encoding="utf-8").read() if os.path.exists(target) else None
    if args.check:
        if current == text:
            print(f"docs/INDEX.md is up to date ({n_guides} guides, {n_diagrams} diagrams)")
            return
        sys.exit("docs/INDEX.md is out of date. Run ./scripts/ts03-docs-index and commit it.")
    with open(target, "w", encoding="utf-8") as f:
        f.write(text)
    print(f"wrote docs/INDEX.md: {n_guides} guides, {n_diagrams} diagrams" + ("" if current != text else " (unchanged)"))


if __name__ == "__main__":
    main()

Questions or corrections: [email protected]