#!/usr/bin/env python3 """ Convert the Doxygen XML output to a Markdown tree readable directly by gitea's web interface. Usage: python3 doc/gen_api_md.py Layout produced (rooted at ): index.md — landing page (classes + files TOC) classes/ index.md — table of classes .md — one per class/struct (members, briefs, source links) files/ index.md — table of source files .md — one per file (compounds defined + free funcs) Source links use relative paths so gitea turns them into clickable links straight to the right line of the source file. """ from __future__ import annotations import re import sys import xml.etree.ElementTree as ET from pathlib import Path # ---------------------------------------------------------------------- # Inline rendering helpers # ---------------------------------------------------------------------- CLASS_REFID_RE = re.compile(r"^(class|struct)([A-Za-z_].*)$") def ref_to_md(refid: str, label: str) -> str: """Turn a Doxygen refid into a Markdown link target if it points to a known class/struct, otherwise just return the label. Links are emitted relative to the `doc/api/` root (so they resolve identically from `classes/` and `files/` pages thanks to the intervening `..`).""" if not refid: return label m = CLASS_REFID_RE.match(refid) if m and "_1" not in refid: return f"[{label}](../classes/{m.group(2)}.md)" return f"`{label}`" if label else refid def plain_text(elem: ET.Element | None) -> str: """Render an element as plain text — no Markdown, no links. Used inside code spans where Markdown syntax wouldn't be rendered anyway.""" if elem is None: return "" parts: list[str] = [] if elem.text: parts.append(elem.text) for c in elem: parts.append(plain_text(c)) if c.tail: parts.append(c.tail) return "".join(parts) def inline_md(elem: ET.Element | None) -> str: """Render a Doxygen inline-text element as Markdown.""" if elem is None: return "" out: list[str] = [] if elem.text: out.append(elem.text) for c in elem: tag = c.tag if tag == "ref": out.append(ref_to_md(c.attrib.get("refid", ""), (c.text or "").strip())) elif tag == "computeroutput": out.append("`" + inline_md(c).strip() + "`") elif tag == "emphasis": out.append("*" + inline_md(c) + "*") elif tag == "bold": out.append("**" + inline_md(c) + "**") elif tag == "linebreak": out.append(" \n") elif tag == "ulink": href = c.attrib.get("url", "") out.append(f"[{(c.text or href).strip()}]({href})") elif tag == "verbatim": out.append("\n\n```\n" + (c.text or "") + "\n```\n\n") else: # Unknown / generic container: recurse, preserves nested text. out.append(inline_md(c)) if c.tail: out.append(c.tail) return "".join(out) def render_para(para: ET.Element) -> tuple[str, list[tuple[str, str]], list[str], list[tuple[str, str]]]: """Render one element. Returns (prose, params, returns, throws). Parameter / return / throws annotations from the para are pulled out so the caller can lay them out separately. """ params: list[tuple[str, str]] = [] returns: list[str] = [] throws: list[tuple[str, str]] = [] out: list[str] = [] if para.text: out.append(para.text) for c in para: tag = c.tag if tag == "parameterlist": kind = c.attrib.get("kind", "") for pi in c.findall("parameteritem"): name_el = pi.find("parameternamelist/parametername") name = (name_el.text or "").strip() if name_el is not None else "" desc = inline_md(pi.find("parameterdescription")).strip() if kind == "param": params.append((name, desc)) elif kind == "exception": throws.append((name, desc)) elif tag == "simplesect": kind = c.attrib.get("kind", "") if kind == "return": returns.append(inline_md(c).strip()) elif kind in ("note", "warning"): out.append(f"\n\n> **{kind.capitalize()}:** {inline_md(c).strip()}\n\n") elif tag == "programlisting": out.append("\n\n```cpp\n") for codeline in c.findall("codeline"): out.append("".join(codeline.itertext()) + "\n") out.append("```\n\n") elif tag == "itemizedlist": out.append("\n\n") for li in c.findall("listitem"): out.append("- " + "\n ".join( render_para(p)[0].strip() for p in li.findall("para") ) + "\n") out.append("\n") elif tag in ("ref", "computeroutput", "emphasis", "bold", "linebreak", "ulink", "verbatim"): # Reuse inline_md for one-element rendering. wrap = ET.Element("_w") wrap.append(c) out.append(inline_md(wrap).rstrip()) else: out.append(inline_md(c)) if c.tail: out.append(c.tail) return "".join(out).strip(), params, returns, throws def render_description(elem: ET.Element | None) -> tuple[str, list[tuple[str, str]], list[str], list[tuple[str, str]]]: """Render or as Markdown + extract @param / @return / @throws lists.""" if elem is None: return "", [], [], [] paras: list[str] = [] params: list[tuple[str, str]] = [] returns: list[str] = [] throws: list[tuple[str, str]] = [] for p in elem.findall("para"): prose, ps, rs, ts = render_para(p) if prose: paras.append(prose) params.extend(ps) returns.extend(rs) throws.extend(ts) return "\n\n".join(paras), params, returns, throws # ---------------------------------------------------------------------- # Source link helpers # ---------------------------------------------------------------------- def src_link(loc: ET.Element | None, depth_to_root: int = 2) -> str: """Build a relative Markdown link to the source location. depth_to_root = number of `..` segments to escape from the .md page back to the repo root. Class/file pages live at `doc/api//.md`, so depth 2 (to `doc/api/`) + an extra 2 to get back to the repo root. """ if loc is None: return "" file_ = loc.attrib.get("file", "") line = loc.attrib.get("line", "") if not file_: return "" prefix = "../" * (depth_to_root + 2) # ../../../../ from doc/api//.md target = f"{prefix}{file_}" if line: target += f"#L{line}" label = Path(file_).name + (f":{line}" if line else "") return f"[{label}]({target})" # ---------------------------------------------------------------------- # Compound rendering # ---------------------------------------------------------------------- def member_signature(m: ET.Element) -> str: """Reconstruct a member's C++ declaration as it should appear in the doc. Uses plain text for the type (no Markdown links) since the signature sits inside a code span where Markdown isn't rendered anyway. Whitespace is normalised so that `Signals *` and `Signals*` look the same. """ kind = m.attrib.get("kind", "") type_ = plain_text(m.find("type")).strip() type_ = re.sub(r"\s+", " ", type_) name = (m.findtext("name") or "").strip() if kind == "function": argstr = (m.findtext("argsstring") or "").strip() return f"{type_} {name}{argstr}".strip() if type_ else f"{name}{argstr}" if kind == "variable": return f"{type_} {name}".strip() if kind == "enum": return f"enum {name}" if kind == "typedef": defn = (m.findtext("definition") or "").strip() return defn or f"typedef {type_} {name}" return name def emit_class(xml_dir: Path, out_dir: Path, refid: str) -> None: tree = ET.parse(xml_dir / f"{refid}.xml") root = tree.find("compounddef") if root is None: return kind = root.attrib.get("kind", "class") name = (root.findtext("compoundname") or "").strip() out_path = out_dir / "classes" / f"{refid[5 if kind == 'class' else 6:]}.md" lines: list[str] = [] lines.append(f"# {name}") lines.append("") header_bits = [f"`{kind} {name}`"] bases = [] for b in root.findall("basecompoundref"): label = (b.text or "").strip() bases.append(ref_to_md(b.attrib.get("refid", ""), label)) if bases: header_bits.append("— inherits " + ", ".join(bases)) lines.append(" ".join(header_bits)) lines.append("") loc = root.find("location") if loc is not None and loc.attrib.get("file"): lines.append(f"Defined in {src_link(loc)}") lines.append("") brief, _, _, _ = render_description(root.find("briefdescription")) if brief: lines.append(brief) lines.append("") detailed, _, _, _ = render_description(root.find("detaileddescription")) if detailed: lines.append(detailed) lines.append("") section_titles = { "public-type": "Public Types", "public-attrib": "Public Attributes", "public-static-attrib": "Public Static Attributes", "public-func": "Public Functions", "public-static-func": "Public Static Functions", "protected-attrib": "Protected Attributes", "protected-func": "Protected Functions", "private-attrib": "Private Attributes", "private-func": "Private Functions", "user-defined": "User-defined", } for sec in root.findall("sectiondef"): sk = sec.attrib.get("kind", "") title = section_titles.get(sk, sk.replace("-", " ").capitalize()) members = sec.findall("memberdef") if not members: continue lines.append(f"## {title}") lines.append("") for m in members: sig = member_signature(m) mloc = m.find("location") link = src_link(mloc) if mloc is not None else "" lines.append(f"### `{sig}`") if link: lines.append("") lines.append(f"📍 {link}") lines.append("") mbrief, _, _, _ = render_description(m.find("briefdescription")) mdetail, params, returns, throws = render_description(m.find("detaileddescription")) if mbrief: lines.append(mbrief) lines.append("") if mdetail: lines.append(mdetail) lines.append("") if params: lines.append("**Parameters**") lines.append("") for pname, pdesc in params: lines.append(f"- `{pname}` — {pdesc}" if pdesc else f"- `{pname}`") lines.append("") if returns: lines.append("**Returns** " + " ".join(returns)) lines.append("") if throws: lines.append("**Throws**") lines.append("") for tname, tdesc in throws: lines.append(f"- `{tname}` — {tdesc}" if tdesc else f"- `{tname}`") lines.append("") lines.append("---") lines.append("") lines.append("← [Back to classes](index.md) · [Top](../index.md)") out_path.write_text("\n".join(lines).rstrip() + "\n") def emit_file(xml_dir: Path, out_dir: Path, refid: str) -> None: tree = ET.parse(xml_dir / f"{refid}.xml") root = tree.find("compounddef") if root is None: return name = (root.findtext("compoundname") or "").strip() safe = refid # already a flat identifier like system_2modules_8hpp out_path = out_dir / "files" / f"{safe}.md" lines: list[str] = [] lines.append(f"# {name}") lines.append("") loc = root.find("location") if loc is not None and loc.attrib.get("file"): lines.append(f"Source: {src_link(loc)}") lines.append("") brief, _, _, _ = render_description(root.find("briefdescription")) if brief: lines.append(brief) lines.append("") detailed, _, _, _ = render_description(root.find("detaileddescription")) if detailed: lines.append(detailed) lines.append("") # Inner compounds (classes/structs/namespaces defined in this file). inner = [(c.attrib.get("refid", ""), (c.text or "").strip()) for c in root.findall("innerclass")] if inner: lines.append("## Defines") lines.append("") for rid, label in inner: lines.append(f"- {ref_to_md(rid, label)}") lines.append("") # Free functions, variables, typedefs at file scope. for sec in root.findall("sectiondef"): sk = sec.attrib.get("kind", "") title_map = { "func": "Free Functions", "var": "Variables", "typedef": "Typedefs", "enum": "Enums", "define": "Macros", } title = title_map.get(sk) if not title: continue members = sec.findall("memberdef") if not members: continue lines.append(f"## {title}") lines.append("") for m in members: sig = member_signature(m) mloc = m.find("location") link = src_link(mloc) if mloc is not None else "" lines.append(f"### `{sig}`") if link: lines.append("") lines.append(f"📍 {link}") lines.append("") mbrief, _, _, _ = render_description(m.find("briefdescription")) mdetail, params, returns, throws = render_description(m.find("detaileddescription")) if mbrief: lines.append(mbrief); lines.append("") if mdetail: lines.append(mdetail); lines.append("") if params: lines.append("**Parameters**"); lines.append("") for pname, pdesc in params: lines.append(f"- `{pname}` — {pdesc}" if pdesc else f"- `{pname}`") lines.append("") if returns: lines.append("**Returns** " + " ".join(returns)); lines.append("") if throws: lines.append("**Throws**"); lines.append("") for tname, tdesc in throws: lines.append(f"- `{tname}` — {tdesc}" if tdesc else f"- `{tname}`") lines.append("") lines.append("---") lines.append("") lines.append("← [Back to files](index.md) · [Top](../index.md)") out_path.write_text("\n".join(lines).rstrip() + "\n") # ---------------------------------------------------------------------- # Index pages # ---------------------------------------------------------------------- def emit_classes_index(out_dir: Path, entries: list[tuple[str, str, str]]) -> None: """entries = list of (refid, name, brief).""" lines: list[str] = [] lines.append("# Classes & Structs") lines.append("") lines.append("| Name | Brief |") lines.append("|---|---|") for refid, name, brief in sorted(entries, key=lambda e: e[1].lower()): m = CLASS_REFID_RE.match(refid) path = f"{m.group(2)}.md" if m else refid lines.append(f"| [`{name}`]({path}) | {brief.replace('|', '\\|').strip() or '—'} |") lines.append("") lines.append("← [Top](../index.md)") (out_dir / "classes" / "index.md").write_text("\n".join(lines) + "\n") def emit_files_index(out_dir: Path, entries: list[tuple[str, str, str]]) -> None: lines: list[str] = [] lines.append("# Source Files") lines.append("") lines.append("| Path | Brief |") lines.append("|---|---|") for refid, name, brief in sorted(entries, key=lambda e: e[1].lower()): lines.append(f"| [`{name}`]({refid}.md) | {brief.replace('|', '\\|').strip() or '—'} |") lines.append("") lines.append("← [Top](../index.md)") (out_dir / "files" / "index.md").write_text("\n".join(lines) + "\n") def emit_top_index(out_dir: Path, n_classes: int, n_files: int) -> None: lines: list[str] = [ "# essim API reference", "", "Auto-generated from Doxygen comments in `src/`. Regenerate with", "`cmake --build build --target doc`. See [doc/README.md](../README.md)", "for the toolchain.", "", f"- [Classes & Structs](classes/index.md) — {n_classes} entries", f"- [Source Files](files/index.md) — {n_files} entries", "", "## Curated reading order", "", "Start with the domain model, then importers, then the TUI:", "", "1. [`System`](classes/System.md) — owns Modules + Connections.", "2. [`Module`](classes/Module.md) → [`Part`](classes/Part.md) → " "[`Pin`](classes/Pin.md) → [`Signal`](classes/Signal.md) — ownership chain.", "3. [`Connection`](classes/Connection.md) — cross-module wiring with `pin_map`.", "4. [`Transform`](classes/Transform.md) / " "[`IdentityTransform`](classes/IdentityTransform.md) — connector-pair → pin pairs.", "5. [`ImportBase`](classes/ImportBase.md) → " "[`ImportMentor`](classes/ImportMentor.md), " "[`ImportAltium`](classes/ImportAltium.md), " "[`ImportOds`](classes/ImportOds.md) — netlist parsers.", "6. [`Tui`](classes/Tui.md) — interactive shell + screen orchestration.", "", ] (out_dir / "index.md").write_text("\n".join(lines) + "\n") # ---------------------------------------------------------------------- # Main # ---------------------------------------------------------------------- def main() -> int: if len(sys.argv) != 3: print(__doc__, file=sys.stderr) return 2 xml_dir = Path(sys.argv[1]) out_dir = Path(sys.argv[2]) if not xml_dir.is_dir(): print(f"xml_dir not found: {xml_dir}", file=sys.stderr) return 1 out_dir.mkdir(parents=True, exist_ok=True) (out_dir / "classes").mkdir(exist_ok=True) (out_dir / "files").mkdir(exist_ok=True) index = ET.parse(xml_dir / "index.xml").getroot() classes: list[tuple[str, str, str]] = [] # (refid, name, brief) files: list[tuple[str, str, str]] = [] for c in index.findall("compound"): kind = c.attrib.get("kind", "") refid = c.attrib.get("refid", "") name = (c.findtext("name") or "").strip() if kind in ("class", "struct"): # Read brief from the per-compound XML. try: tree = ET.parse(xml_dir / f"{refid}.xml").getroot().find("compounddef") brief, _, _, _ = render_description(tree.find("briefdescription")) if tree is not None else ("", [], [], []) except (FileNotFoundError, ET.ParseError): brief = "" classes.append((refid, name, brief)) emit_class(xml_dir, out_dir, refid) elif kind == "file": try: tree = ET.parse(xml_dir / f"{refid}.xml").getroot().find("compounddef") brief, _, _, _ = render_description(tree.find("briefdescription")) if tree is not None else ("", [], [], []) except (FileNotFoundError, ET.ParseError): brief = "" files.append((refid, name, brief)) emit_file(xml_dir, out_dir, refid) emit_classes_index(out_dir, classes) emit_files_index(out_dir, files) emit_top_index(out_dir, len(classes), len(files)) print(f"Wrote {len(classes)} class page(s) and {len(files)} file page(s) " f"to {out_dir}") return 0 if __name__ == "__main__": sys.exit(main())