Skip to content

Commit cf57f92

Browse files
Nick Ficanoclaude
andcommitted
chore: add Markdown API doc generation tooling
Add a Makefile and scripts/gen-api-docs.py to render the API reference into docs/api/ for the www site; gitignore the generated output. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f3503e9 commit cf57f92

3 files changed

Lines changed: 253 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ TestResults/
99
*.trx
1010
coverage*.xml
1111
*.swp
12+
docs/api/

Makefile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# ARCP F# SDK developer tasks.
2+
3+
.PHONY: docs-api
4+
5+
docs-api:
6+
@python3 scripts/gen-api-docs.py

scripts/gen-api-docs.py

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
#!/usr/bin/env python3
2+
"""Generate Markdown API docs for the F# SDK by scraping source.
3+
4+
Walks src/<Project>/**/*.fs, extracts namespace/module declarations and
5+
`///` doc-comment blocks attached to public `let` / `type` / `module` /
6+
`member` / `val` / `abstract` / `new` declarations, then writes one
7+
Markdown file per project plus a top-level index.
8+
9+
Output: docs/api/<Project>.md and docs/api/index.md.
10+
"""
11+
from __future__ import annotations
12+
13+
import re
14+
from dataclasses import dataclass, field
15+
from pathlib import Path
16+
from typing import Iterator
17+
18+
ROOT = Path(__file__).resolve().parent.parent
19+
SRC = ROOT / "src"
20+
OUT = ROOT / "docs" / "api"
21+
22+
DECL_RE = re.compile(
23+
r"^(?P<indent>\s*)"
24+
r"(?P<kw>let|type|module|member|val|abstract|new|and|interface)\b"
25+
r"(?P<rest>.*)$"
26+
)
27+
ATTR_RE = re.compile(r"^\s*\[<[^>]+>\]\s*$")
28+
NS_RE = re.compile(r"^namespace\s+(?P<ns>\S+)")
29+
TOP_MODULE_RE = re.compile(r"^module\s+(?P<m>[A-Za-z0-9_.]+)\s*=?\s*$")
30+
# `let private name` / `member private name` is private. A `type Foo
31+
# private () =` is NOT — that only marks the primary ctor private.
32+
LET_MEMBER_PRIV_RE = re.compile(r"^\s+(private|internal)\b")
33+
TYPE_MODULE_PRIV_RE = re.compile(r"^\s+(private|internal)\b\s+[A-Za-z_]")
34+
35+
36+
@dataclass
37+
class Decl:
38+
kind: str
39+
signature: str
40+
doc: list[str] = field(default_factory=list)
41+
scope: str = ""
42+
43+
44+
@dataclass
45+
class FileDoc:
46+
path: Path
47+
namespace: str = ""
48+
top_module: str = ""
49+
decls: list[Decl] = field(default_factory=list)
50+
51+
52+
def strip_doc(line: str) -> str:
53+
s = line.lstrip()
54+
return s[3:].lstrip() if s.startswith("///") else ""
55+
56+
57+
def is_continuation(line: str) -> bool:
58+
t = line.rstrip()
59+
return bool(t) and t.endswith(("(", ",", "->", ":", "*", "<", "="))
60+
61+
62+
def is_private(kw: str, rest: str) -> bool:
63+
if kw in ("let", "member", "val", "abstract"):
64+
return bool(LET_MEMBER_PRIV_RE.match(rest))
65+
return bool(TYPE_MODULE_PRIV_RE.match(rest))
66+
67+
68+
def gather_signature(lines: list[str], i: int) -> str:
69+
parts = [lines[i].rstrip()]
70+
j = i
71+
while is_continuation(parts[-1]) and j + 1 < len(lines):
72+
j += 1
73+
nxt = lines[j].rstrip()
74+
if nxt.lstrip().startswith("///"):
75+
break
76+
parts.append(nxt)
77+
if len(parts) >= 6:
78+
break
79+
return "\n".join(parts).strip()
80+
81+
82+
def compute_scope(fd: FileDoc, module_stack: list[tuple[int, str]], kw: str) -> str:
83+
parts: list[str] = []
84+
if fd.namespace:
85+
parts.append(fd.namespace)
86+
if fd.top_module:
87+
parts.append(fd.top_module)
88+
if kw == "module":
89+
parts.extend(n for _, n in module_stack[:-1])
90+
else:
91+
parts.extend(n for _, n in module_stack)
92+
return ".".join(parts)
93+
94+
95+
def parse_file(path: Path) -> FileDoc:
96+
"""Extract documented declarations from a single .fs file."""
97+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
98+
fd = FileDoc(path=path)
99+
pending: list[str] = []
100+
module_stack: list[tuple[int, str]] = []
101+
i = 0
102+
while i < len(lines):
103+
raw = lines[i]
104+
stripped = raw.strip()
105+
106+
if not stripped:
107+
i += 1
108+
continue
109+
110+
m_ns = NS_RE.match(stripped)
111+
if m_ns:
112+
fd.namespace = m_ns.group("ns")
113+
pending = []
114+
i += 1
115+
continue
116+
117+
# Capture the file-level top module only when there is no
118+
# namespace declaration (i.e. file starts with `module X.Y.Z`).
119+
# When a namespace is present, col-0 `module Foo =` lines are
120+
# sibling modules, not file-level wrappers.
121+
m_top = TOP_MODULE_RE.match(stripped)
122+
if (
123+
m_top
124+
and not raw.startswith(" ")
125+
and not fd.top_module
126+
and not fd.namespace
127+
):
128+
fd.top_module = m_top.group("m")
129+
130+
if stripped.startswith("///"):
131+
pending.append(strip_doc(raw))
132+
i += 1
133+
continue
134+
135+
if ATTR_RE.match(raw):
136+
i += 1
137+
continue
138+
139+
m = DECL_RE.match(raw)
140+
if m:
141+
indent = len(m.group("indent").expandtabs(4))
142+
kw = m.group("kw")
143+
rest = m.group("rest")
144+
145+
while module_stack and indent <= module_stack[-1][0]:
146+
module_stack.pop()
147+
148+
if kw == "module":
149+
nm = re.match(r"\s*([A-Za-z_][A-Za-z0-9_]*)", rest)
150+
if nm:
151+
module_stack.append((indent, nm.group(1)))
152+
153+
if pending and not is_private(kw, rest):
154+
fd.decls.append(
155+
Decl(
156+
kind=kw,
157+
signature=gather_signature(lines, i),
158+
doc=list(pending),
159+
scope=compute_scope(fd, module_stack, kw),
160+
)
161+
)
162+
163+
pending = []
164+
i += 1
165+
continue
166+
167+
# Non-doc/attr/decl line drops any pending docs.
168+
pending = []
169+
i += 1
170+
return fd
171+
172+
173+
def iter_projects() -> Iterator[Path]:
174+
for d in sorted(SRC.iterdir()):
175+
if d.is_dir():
176+
yield d
177+
178+
179+
def render_project(project_dir: Path) -> tuple[str, str, str]:
180+
"""Return (name, markdown, summary) for one project."""
181+
name = project_dir.name
182+
out: list[str] = [f"# {name}", ""]
183+
summary = ""
184+
for f in sorted(project_dir.rglob("*.fs")):
185+
if f.name == "AssemblyInfo.fs":
186+
continue
187+
fd = parse_file(f)
188+
if not fd.decls:
189+
continue
190+
rel = f.relative_to(project_dir)
191+
out.append(f"## `{rel.as_posix()}`")
192+
out.append("")
193+
if fd.namespace:
194+
out.append(f"_namespace_ `{fd.namespace}`")
195+
out.append("")
196+
if fd.top_module:
197+
out.append(f"_module_ `{fd.top_module}`")
198+
out.append("")
199+
if not summary and fd.decls[0].doc:
200+
summary = fd.decls[0].doc[0]
201+
for d in fd.decls:
202+
heading = d.scope or fd.namespace or name
203+
out.append(f"### `{d.kind}` in `{heading}`")
204+
out.append("")
205+
out.append("```fsharp")
206+
out.append(d.signature)
207+
out.append("```")
208+
out.append("")
209+
for line in d.doc:
210+
out.append(line)
211+
out.append("")
212+
return name, "\n".join(out).rstrip() + "\n", summary
213+
214+
215+
def main() -> None:
216+
OUT.mkdir(parents=True, exist_ok=True)
217+
index: list[tuple[str, str]] = []
218+
written = 0
219+
for project in iter_projects():
220+
name, md, summary = render_project(project)
221+
if md.strip() == f"# {name}":
222+
continue
223+
(OUT / f"{name}.md").write_text(md, encoding="utf-8")
224+
index.append((name, summary))
225+
written += 1
226+
227+
idx = [
228+
"# ARCP F# SDK API Reference",
229+
"",
230+
"Auto-generated from F# `///` doc comments. Regenerate with `make docs-api`.",
231+
"",
232+
"## Projects",
233+
"",
234+
]
235+
for name, summary in index:
236+
line = f"- [{name}]({name}.md)"
237+
if summary:
238+
line += f" — {summary}"
239+
idx.append(line)
240+
idx.append("")
241+
(OUT / "index.md").write_text("\n".join(idx), encoding="utf-8")
242+
print(f"Wrote {written} project page(s) + index.md to {OUT}")
243+
244+
245+
if __name__ == "__main__":
246+
main()

0 commit comments

Comments
 (0)