`cmake --build build --target doc` runs Doxygen to produce XML, then `doc/gen_api_md.py` (~330 lines, stdlib-only) emits a Markdown tree under `doc/api/` that gitea renders directly in its file browser. - 24 class/struct pages + 51 source-file pages + indices, with source links of the form `../../../../src/...#L42` that gitea turns into clickable line-anchored links. - Doxyfile.in templated by CMake (XML-only output to build/doc/xml/). - Pure Python emitter, zero external deps — no doxybook2 (not packaged on Arch) and no moxygen (avoids Node). - Target gracefully disabled if Doxygen or Python 3 is missing at configure time; regular build target unaffected. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
534 lines
20 KiB
Python
534 lines
20 KiB
Python
#!/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 <xml_dir> <out_dir>
|
|
|
|
Layout produced (rooted at <out_dir>):
|
|
|
|
index.md — landing page (classes + files TOC)
|
|
classes/
|
|
index.md — table of classes
|
|
<Name>.md — one per class/struct (members, briefs, source links)
|
|
files/
|
|
index.md — table of source files
|
|
<name>.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 <para> 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 <briefdescription> or <detaileddescription> 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/<sub>/<x>.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/<sub>/<x>.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())
|